1650 lines
73 KiB
Markdown
1650 lines
73 KiB
Markdown
# Completed Game Portfolio Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add moderated public portfolios of completed adventures with multi-session grouping, uploaded covers, GM-profile and club visibility, and participant-submitted reviews.
|
|
|
|
**Architecture:** Add a bounded portfolio vertical slice in `GmRelay.Web`: `IPortfolioStore`/`PortfolioService` own PostgreSQL persistence, `AuthorizedPortfolioService` owns current-user checks and orchestration, and `IPortfolioCoverStorage` isolates local volume storage from a future S3 implementation. Existing `/showcase` recruitment queries remain unchanged. Public Razor pages consume sanitized DTOs only.
|
|
|
|
**Tech Stack:** .NET 10, Blazor Server, PostgreSQL, Npgsql, Dapper, DbUp SQL migrations, xUnit, Docker Compose.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**Create**
|
|
|
|
- `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`
|
|
- `src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/PortfolioService.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs`
|
|
- `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs`
|
|
- `src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor`
|
|
- `src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor`
|
|
- `src/GmRelay.Web/Components/Pages/PortfolioEditor.razor`
|
|
- `src/GmRelay.Web/Components/Pages/PublicPortfolio.razor`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs`
|
|
- `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs`
|
|
|
|
**Modify**
|
|
|
|
- `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`
|
|
- `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`
|
|
- `src/GmRelay.AppHost/Program.cs`
|
|
- `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
|
|
- `tests/GmRelay.Bot.Tests/packages.lock.json`
|
|
- `src/GmRelay.Web/Program.cs`
|
|
- `src/GmRelay.Web/appsettings.Development.json`
|
|
- `src/GmRelay.Web/Dockerfile`
|
|
- `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
|
|
- `src/GmRelay.Web/Components/Pages/SessionHistory.razor`
|
|
- `src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor`
|
|
- `src/GmRelay.Web/Components/Pages/PublicClub.razor`
|
|
- `src/GmRelay.Web/wwwroot/app.css`
|
|
- `.env.example`
|
|
- `compose.yaml`
|
|
- `README.md`
|
|
- `docs/c4-system-context.md`
|
|
- `Directory.Build.props`
|
|
- `.gitea/workflows/deploy.yml`
|
|
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
|
|
---
|
|
|
|
### Task 1: Add Portfolio Schema
|
|
|
|
**Quality-review fix index**
|
|
|
|
- `d591e5e` `fix(data): protect portfolio publication invariant`
|
|
- `3c1a98b` `fix(data): harden portfolio publication concurrency`
|
|
- `76b3ff7` `fix(data): serialize portfolio publication validation`
|
|
- `6e7a0cb` `fix(data): enforce portfolio validation isolation`
|
|
- `f493836` `fix(data): reject stale portfolio trigger snapshots`
|
|
- `da0a306` `fix(data): enforce completed portfolio sessions`
|
|
- `a28b75d` `fix(data): align portfolio mutation lock order`
|
|
- `d762ecc` `fix(data): serialize portfolio future reschedules`
|
|
- `1d62f69` `fix(data): lock racing portfolio publications`
|
|
- `ea71448` `fix(data): serialize new-link publication races`
|
|
- `1a81610` `fix(data): reject stale reschedule snapshots`
|
|
- `a20da4b` `fix(data): serialize portfolio mutations before rows`
|
|
|
|
**Files:**
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs`
|
|
- Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`
|
|
- Modify: `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`
|
|
- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`
|
|
- Modify: `src/GmRelay.AppHost/Program.cs`
|
|
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
|
|
- Modify: `tests/GmRelay.Bot.Tests/packages.lock.json`
|
|
- Modify: `compose.yaml`
|
|
|
|
- [ ] **Step 1: Write the failing migration and session-deletion source-contract tests**
|
|
|
|
Add tests that read `V029__add_completed_game_portfolios_and_reviews.sql` and assert:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards()
|
|
{
|
|
var migration = await ReadRepositoryFileAsync(
|
|
"src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");
|
|
var normalizedMigration = NormalizeSql(migration);
|
|
|
|
Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal);
|
|
Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal);
|
|
Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal);
|
|
Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal);
|
|
Assert.Contains("'Pending', 'Approved', 'Rejected', 'Hidden'", migration, StringComparison.Ordinal);
|
|
Assert.Contains("publication_consent_at", migration, StringComparison.Ordinal);
|
|
Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal);
|
|
Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal);
|
|
Assert.Contains("format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE UNIQUE INDEX ux_portfolio_games_public_slug ON portfolio_games (lower(public_slug)) WHERE public_slug IS NOT NULL;", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE INDEX ix_portfolio_games_group ON portfolio_games (group_id, completed_at DESC);", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id);", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE FUNCTION lock_portfolio_publication_mutation() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_portfolio_games_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation BEFORE DELETE OR UPDATE OF scheduled_at ON sessions FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON game_groups FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON players FOR EACH STATEMENT", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal);
|
|
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
|
|
}
|
|
```
|
|
|
|
Add a second test asserting the public-card columns are provider-neutral:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys()
|
|
{
|
|
var migration = await ReadRepositoryFileAsync(
|
|
"src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");
|
|
|
|
Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
```
|
|
|
|
Add `PortfolioSessionDeletionSourceTests.cs`. Normalize whitespace before comparing source text and assert that both session-deletion paths acquire the portfolio mutation lock, explicitly lock the target session row, unpublish linked cards, and then delete the required session link:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
|
{
|
|
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
|
"src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs"));
|
|
|
|
const string sessionLock =
|
|
"FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s";
|
|
const string mutationLock =
|
|
"SELECT pg_advisory_xact_lock(20260530, 108)";
|
|
const string unpublish =
|
|
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = @SessionId AND pg.is_public = true";
|
|
|
|
Assert.Contains(mutationLock, source, StringComparison.Ordinal);
|
|
Assert.Contains(sessionLock, source, StringComparison.Ordinal);
|
|
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
|
Assert.True(
|
|
source.IndexOf(mutationLock, StringComparison.Ordinal) <
|
|
source.IndexOf(sessionLock, StringComparison.Ordinal));
|
|
Assert.True(
|
|
source.IndexOf(sessionLock, StringComparison.Ordinal) <
|
|
source.IndexOf(unpublish, StringComparison.Ordinal));
|
|
Assert.True(
|
|
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
|
source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
|
{
|
|
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
|
"src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs"));
|
|
|
|
const string sessionLock =
|
|
"SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s";
|
|
const string unpublish =
|
|
"UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true";
|
|
|
|
Assert.Contains(sessionLock, source, StringComparison.Ordinal);
|
|
Assert.Contains(unpublish, source, StringComparison.Ordinal);
|
|
Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal);
|
|
Assert.True(
|
|
source.IndexOf(sessionLock, StringComparison.Ordinal) <
|
|
source.IndexOf(unpublish, StringComparison.Ordinal));
|
|
Assert.True(
|
|
source.IndexOf(unpublish, StringComparison.Ordinal) <
|
|
source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal));
|
|
}
|
|
```
|
|
|
|
Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: use database resource name `.AddDatabase("gmrelaydb")`, save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint with `isProxied: false`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate without a proxy competing with its `HttpListener`.
|
|
|
|
- [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests**
|
|
|
|
Add the package reference:
|
|
|
|
```xml
|
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
|
```
|
|
|
|
Update the locked dependency graph:
|
|
|
|
```powershell
|
|
dotnet restore tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --use-lock-file
|
|
```
|
|
|
|
Create `PortfolioMigrationPostgresFixture.cs` with a shared `PostgreSqlContainer` built from `postgres:17-alpine`. For each test, create a fresh database and apply migration files `V001` through `V029` in ordinal filename order.
|
|
|
|
Create `PortfolioMigrationPostgresTests.cs` with these executable scenarios:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17()
|
|
|
|
[Theory]
|
|
[InlineData("portfolio_game_sessions")]
|
|
[InlineData("portfolio_game_masters")]
|
|
public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable)
|
|
|
|
[Fact]
|
|
public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt()
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task ConcurrentPublishAndLinkDelete_ShouldSerializeBeforeRowsAndRejectInvalidPublicCard(bool publishMutatesFirst)
|
|
|
|
[Theory]
|
|
[InlineData("portfolio_game_sessions", "session_id")]
|
|
[InlineData("portfolio_game_masters", "player_id")]
|
|
public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidPublicCard(string linkTable, string linkColumn)
|
|
|
|
[Theory]
|
|
[InlineData("portfolio_game_sessions", "session_id")]
|
|
[InlineData("portfolio_game_masters", "player_id")]
|
|
public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn)
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard(bool publishCommitsFirst)
|
|
|
|
[Fact]
|
|
public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt()
|
|
|
|
[Fact]
|
|
public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt()
|
|
|
|
[Fact]
|
|
public async Task ConcurrentBatchFutureReschedules_ShouldSerializeBeforeSessionRowsWithoutDeadlock()
|
|
|
|
[Fact]
|
|
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
|
|
|
|
[Fact]
|
|
public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard()
|
|
|
|
[Fact]
|
|
public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard()
|
|
|
|
[Fact]
|
|
public async Task PortfolioSessionLinkInsert_ShouldAcquirePublicationLockBeforeRows()
|
|
|
|
[Fact]
|
|
public async Task FutureReschedule_ShouldAcquirePublicationLockBeforeSessionRows()
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeMutationGateBeforeRowsWithoutDeadlock(bool deleteMutatesFirst)
|
|
|
|
[Fact]
|
|
public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard()
|
|
|
|
[Theory]
|
|
[InlineData("portfolio_game_sessions")]
|
|
[InlineData("portfolio_game_masters")]
|
|
public async Task MovingLastRequiredLinkAway_ShouldFailCommitForPublishedCard(string linkTable)
|
|
|
|
[Theory]
|
|
[InlineData("sessions")]
|
|
[InlineData("players")]
|
|
public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(string parentTable)
|
|
|
|
[Fact]
|
|
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
|
|
```
|
|
|
|
The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A final future reschedule must atomically unpublish linked public cards while preserving their first `published_at`; `past -> future -> past` in one transaction must leave the card public. Opposing-order batch reschedules must prove with `pg_blocking_pids` observation and bounded timeouts that the shared mutation lock serializes statements before session rows, complete without deadlock, and leave both cards private; do not rely on `pg_sleep` timing. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common advisory-lock then `sessions` then `portfolio_games` lock order, cover both first-mutation-lock orders through real blocking transactions, and finish with the card private and session deleted. Link insertion and final-future reschedule gate scenarios must prove that invariant-affecting statements acquire the shared mutation lock before rows. The publish/reschedule races must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders and stale-snapshot final-future reschedules after a newly linked publication, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully.
|
|
|
|
- [ ] **Step 3: Run the Task 1 tests to verify RED**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
|
|
```
|
|
|
|
Expected during this Task 1 quality-review fix: FAIL because session-deletion handlers do not yet lock `sessions` before linked cards and the Aspire AppHost does not yet attach the bot HTTP health check used by `.WaitFor(bot)`.
|
|
|
|
- [ ] **Step 4: Add migration V029**
|
|
|
|
Create the migration with these exact tables and indexes:
|
|
|
|
```sql
|
|
CREATE TABLE portfolio_games (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
|
public_slug VARCHAR(160),
|
|
title VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
cover_storage_key TEXT,
|
|
system VARCHAR(50),
|
|
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
|
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
is_public BOOLEAN NOT NULL DEFAULT false,
|
|
published_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CHECK (NOT is_public OR (
|
|
public_slug IS NOT NULL
|
|
AND description IS NOT NULL
|
|
AND cover_storage_key IS NOT NULL
|
|
AND published_at IS NOT NULL
|
|
))
|
|
);
|
|
|
|
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
|
|
ON portfolio_games (lower(public_slug))
|
|
WHERE public_slug IS NOT NULL;
|
|
|
|
CREATE INDEX ix_portfolio_games_group
|
|
ON portfolio_games (group_id, completed_at DESC);
|
|
|
|
CREATE INDEX ix_portfolio_games_public
|
|
ON portfolio_games (completed_at DESC)
|
|
WHERE is_public = true;
|
|
|
|
CREATE TABLE portfolio_game_sessions (
|
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (portfolio_game_id, session_id),
|
|
UNIQUE (session_id)
|
|
);
|
|
|
|
CREATE TABLE portfolio_game_masters (
|
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
|
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (portfolio_game_id, player_id)
|
|
);
|
|
|
|
CREATE INDEX ix_portfolio_game_masters_player
|
|
ON portfolio_game_masters (player_id, portfolio_game_id);
|
|
|
|
CREATE FUNCTION lock_portfolio_publication_mutation()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
|
RETURN NULL;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
|
|
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
|
|
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
|
|
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
|
|
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
|
|
BEFORE DELETE ON game_groups
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
|
|
BEFORE DELETE ON players
|
|
FOR EACH STATEMENT
|
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
|
|
|
CREATE FUNCTION validate_public_portfolio_game_required_links()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
target_portfolio_game_id UUID;
|
|
target_portfolio_game_ids UUID[];
|
|
BEGIN
|
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
|
|
|
IF TG_TABLE_NAME = 'portfolio_games' THEN
|
|
target_portfolio_game_ids := ARRAY[NEW.id];
|
|
ELSIF TG_OP = 'DELETE' THEN
|
|
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
|
|
ELSIF TG_OP = 'INSERT' THEN
|
|
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
|
|
ELSE
|
|
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
|
|
END IF;
|
|
|
|
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
|
RAISE EXCEPTION
|
|
'portfolio publication validation requires read committed isolation'
|
|
USING ERRCODE = '0A000';
|
|
END IF;
|
|
|
|
SELECT pg.id
|
|
INTO target_portfolio_game_id
|
|
FROM portfolio_games pg
|
|
WHERE pg.id = ANY(target_portfolio_game_ids)
|
|
AND pg.is_public = true
|
|
AND (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM portfolio_game_sessions pgs
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM portfolio_game_sessions pgs
|
|
JOIN sessions s ON s.id = pgs.session_id
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
AND s.scheduled_at >= now()
|
|
)
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM portfolio_game_masters pgm
|
|
WHERE pgm.portfolio_game_id = pg.id
|
|
)
|
|
)
|
|
LIMIT 1;
|
|
|
|
IF target_portfolio_game_id IS NOT NULL THEN
|
|
RAISE EXCEPTION
|
|
'published portfolio game % must have at least one linked session and at least one linked master',
|
|
target_portfolio_game_id
|
|
USING ERRCODE = '23514';
|
|
END IF;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$;
|
|
|
|
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
final_scheduled_at TIMESTAMPTZ;
|
|
BEGIN
|
|
SELECT s.scheduled_at
|
|
INTO final_scheduled_at
|
|
FROM sessions s
|
|
WHERE s.id = NEW.id;
|
|
|
|
IF final_scheduled_at >= now() THEN
|
|
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
|
RAISE EXCEPTION
|
|
'portfolio future reschedule requires read committed isolation'
|
|
USING ERRCODE = '0A000';
|
|
END IF;
|
|
|
|
PERFORM pg.id
|
|
FROM portfolio_games pg
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM portfolio_game_sessions pgs
|
|
JOIN sessions s ON s.id = pgs.session_id
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
AND s.scheduled_at >= now()
|
|
)
|
|
ORDER BY pg.id
|
|
FOR UPDATE OF pg;
|
|
|
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
|
|
|
UPDATE portfolio_games pg
|
|
SET is_public = false,
|
|
updated_at = now()
|
|
WHERE pg.is_public = true
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM portfolio_game_sessions pgs
|
|
JOIN sessions s ON s.id = pgs.session_id
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
AND s.scheduled_at >= now()
|
|
);
|
|
END IF;
|
|
|
|
RETURN NULL;
|
|
END;
|
|
$$;
|
|
|
|
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
|
|
AFTER UPDATE OF scheduled_at ON sessions
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
|
|
|
|
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
|
|
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
|
|
|
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
|
|
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
|
|
|
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
|
|
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
|
|
DEFERRABLE INITIALLY DEFERRED
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
|
|
|
CREATE TABLE portfolio_game_reviews (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
|
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
|
author_display_name VARCHAR(255) NOT NULL,
|
|
body TEXT NOT NULL,
|
|
publication_consent_at TIMESTAMPTZ NOT NULL,
|
|
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
|
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
|
|
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
|
moderated_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (portfolio_game_id, author_player_id)
|
|
);
|
|
|
|
CREATE INDEX ix_portfolio_game_reviews_author
|
|
ON portfolio_game_reviews (author_player_id);
|
|
|
|
CREATE INDEX ix_portfolio_game_reviews_moderator
|
|
ON portfolio_game_reviews (moderated_by_player_id)
|
|
WHERE moderated_by_player_id IS NOT NULL;
|
|
|
|
CREATE INDEX ix_portfolio_game_reviews_public
|
|
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
|
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
|
|
|
|
CREATE INDEX ix_portfolio_game_reviews_pending
|
|
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
|
WHERE moderation_status = 'Pending';
|
|
```
|
|
|
|
The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. Immediate statement triggers acquire one transaction-level advisory lock before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. The intentionally global lock is appropriate for low-volume portfolio and schedule writes: under the application default `READ COMMITTED` isolation level it establishes one advisory-lock then row-lock protocol, prevents write-skew across distinct child links, and removes card/advisory and session/advisory inversions. At transaction commit validators re-acquire the same lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger re-reads the final session row, skips intermediate future values that end in the past, and rejects final-future reschedules outside `READ COMMITTED` with `0A000`. Under `READ COMMITTED`, it locks all cards linked to any final-future session in `portfolio_games.id` order, re-acquires the shared advisory lock, and runs one guarded public-card unpublish update with a fresh statement snapshot. The row-lock phase deliberately includes committed drafts so a concurrent draft-to-public publication cannot pass validation against the pre-reschedule session snapshot and commit afterward. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same advisory-lock then `sessions` then `portfolio_games` order: explicitly acquire the mutation lock, lock the target session row, unpublish linked cards, then delete the session.
|
|
|
|
- [ ] **Step 5: Lock sessions before explicitly unpublishing linked cards in both session-deletion handlers**
|
|
|
|
In `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`, acquire `pg_advisory_xact_lock(20260530, 108)` immediately after starting the transaction, then strengthen the initial session fetch with `FOR UPDATE OF s`. After authorization, run this statement inside the existing transaction before `DELETE FROM sessions`:
|
|
|
|
```sql
|
|
UPDATE portfolio_games pg
|
|
SET is_public = false,
|
|
updated_at = now()
|
|
FROM portfolio_game_sessions pgs
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
AND pgs.session_id = @SessionId
|
|
AND pg.is_public = true
|
|
```
|
|
|
|
In `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`, start a transaction before deleting and acquire `pg_advisory_xact_lock(20260530, 108)`. Lock the guild-scoped target session row with `SELECT s.id ... FOR UPDATE OF s`, preserving the existing not-found result. Run this guild-scoped unpublish statement before the existing guild-scoped `DELETE FROM sessions`, then commit:
|
|
|
|
```sql
|
|
UPDATE portfolio_games pg
|
|
SET is_public = false,
|
|
updated_at = now()
|
|
FROM portfolio_game_sessions pgs
|
|
JOIN sessions s ON s.id = pgs.session_id
|
|
JOIN game_groups g ON g.id = s.group_id
|
|
WHERE pgs.portfolio_game_id = pg.id
|
|
AND s.id = @SessionId
|
|
AND g.platform = 'Discord'
|
|
AND g.external_group_id = @GuildId
|
|
AND pg.is_public = true
|
|
```
|
|
|
|
Both handlers deliberately use advisory-lock then `sessions` then `portfolio_games` locking before session deletion. This matches future rescheduling, keeps normal deletes successful, preserves the first-publication `published_at`, and leaves the triggers as the direct-SQL and concurrency backstop.
|
|
|
|
Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization.
|
|
|
|
In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: use `.AddDatabase("gmrelaydb")` to match application connection-string configuration, save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator or binding an Aspire proxy to the bot `HttpListener` port.
|
|
|
|
- [ ] **Step 6: Run the Task 1 tests to verify GREEN**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
|
|
```
|
|
|
|
Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after final future reschedule, preserved public state after `past -> future -> past`, statement-level mutation locking before session and required-link rows, opposing-order batch reschedules without card deadlock, bounded `READ COMMITTED` publish/delete in both commit orders, existing-link and new-link publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating with a non-proxied bot endpoint and matching `gmrelaydb` resource name.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs
|
|
git commit -m "fix(data): serialize portfolio future reschedules"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Define Portfolio Contracts And Validation
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs`
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs`
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing privacy and validation tests**
|
|
|
|
Add reflection tests that assert `PublicPortfolioCard`, `PublicPortfolioGame`, `PublicPortfolioMaster`, and `PublicPortfolioReview` do not expose names containing:
|
|
|
|
```csharp
|
|
var forbidden = new[]
|
|
{
|
|
"Id", "External", "Telegram", "Discord", "Moderator",
|
|
"StorageKey", "PhysicalPath", "JoinLink", "Session"
|
|
};
|
|
```
|
|
|
|
Add validation tests:
|
|
|
|
```csharp
|
|
[Theory]
|
|
[InlineData(" Dragon Heist ", "dragon-heist")]
|
|
[InlineData("dragon_heist", "dragon-heist")]
|
|
public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected)
|
|
{
|
|
Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("ab")]
|
|
[InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")]
|
|
[InlineData("кириллица")]
|
|
public void NormalizeSlug_ShouldRejectInvalidSlug(string input)
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void NormalizeReviewBody_ShouldRejectBlankText(string body)
|
|
{
|
|
Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeReviewBody(body));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run Task 2 tests to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioContractsTests|FullyQualifiedName~PortfolioValidationTests"
|
|
```
|
|
|
|
Expected: FAIL because the portfolio contracts and validation helper do not exist.
|
|
|
|
- [ ] **Step 3: Add contracts and interface**
|
|
|
|
Define these public sanitized records in `PortfolioContracts.cs`:
|
|
|
|
```csharp
|
|
public sealed record PublicPortfolioCard(
|
|
string Slug,
|
|
string Title,
|
|
string CoverPath,
|
|
string? System,
|
|
string? Format,
|
|
DateTime CompletedAt);
|
|
|
|
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
|
|
|
public sealed record PublicPortfolioReview(
|
|
string AuthorDisplayName,
|
|
string Body,
|
|
DateTime CreatedAt);
|
|
|
|
public sealed record PublicPortfolioGame(
|
|
string Slug,
|
|
string Title,
|
|
string Description,
|
|
string CoverPath,
|
|
string? System,
|
|
string? Format,
|
|
DateTime CompletedAt,
|
|
string? ClubName,
|
|
string? ClubSlug,
|
|
IReadOnlyList<PublicPortfolioMaster> Masters,
|
|
IReadOnlyList<PublicPortfolioReview> Reviews);
|
|
```
|
|
|
|
Define protected records with IDs for editing:
|
|
|
|
```csharp
|
|
public sealed record PortfolioGameSummary(
|
|
Guid Id, Guid GroupId, string Title, string? PublicSlug, bool IsPublic,
|
|
DateTime CompletedAt, int SessionCount, int MasterCount, int PendingReviewCount);
|
|
|
|
public sealed record PortfolioSessionOption(
|
|
Guid Id, string Title, DateTime ScheduledAt, bool Selected);
|
|
|
|
public sealed record PortfolioMasterOption(
|
|
Guid PlayerId, string DisplayName, bool Selected);
|
|
|
|
public sealed record PortfolioReviewForModeration(
|
|
Guid Id, string AuthorDisplayName, string Body, string ModerationStatus, DateTime CreatedAt);
|
|
|
|
public sealed record PortfolioGameEditor(
|
|
Guid Id, Guid GroupId, string Title, string? PublicSlug, string? Description,
|
|
string? CoverPath, string? System, string? Format, DateTime CompletedAt, bool IsPublic,
|
|
IReadOnlyList<PortfolioSessionOption> Sessions,
|
|
IReadOnlyList<PortfolioMasterOption> Masters,
|
|
IReadOnlyList<PortfolioReviewForModeration> Reviews);
|
|
|
|
public sealed record PortfolioGameUpdate(
|
|
string Title, string? PublicSlug, string? Description, string? System, string? Format,
|
|
IReadOnlyCollection<Guid> SessionIds, IReadOnlyCollection<Guid> MasterPlayerIds);
|
|
|
|
public enum PortfolioReviewSubmissionState
|
|
{
|
|
RequiresAuthentication,
|
|
Ineligible,
|
|
Eligible,
|
|
AlreadySubmitted
|
|
}
|
|
```
|
|
|
|
Define `IPortfolioStore` with:
|
|
|
|
```csharp
|
|
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
|
|
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
|
|
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
|
|
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
|
|
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
|
|
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
|
|
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
|
|
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
|
|
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
|
|
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
|
|
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
|
|
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
|
|
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
|
|
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
|
|
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
|
|
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
|
|
```
|
|
|
|
- [ ] **Step 4: Add validation helper**
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
public static string NormalizeSlug(string? value)
|
|
```
|
|
|
|
Rules: trim, lowercase invariant, replace spaces and underscores with `-`, collapse repeated `-`, trim `-`, require length `3..160`, require regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
public static string NormalizeTitle(string? value)
|
|
```
|
|
|
|
Rules: trim, require length `2..255`.
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
public static string? NormalizeDescription(string? value)
|
|
```
|
|
|
|
Rules: null for whitespace, otherwise trim, maximum `5000`.
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
public static string NormalizeReviewBody(string? value)
|
|
```
|
|
|
|
Rules: trim, require length `10..2000`.
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
public static string? NormalizeFormat(string? value)
|
|
```
|
|
|
|
Rules: null for whitespace; otherwise accept only `Online`, `Offline`, `Hybrid`.
|
|
|
|
- [ ] **Step 5: Run Task 2 tests to verify GREEN**
|
|
|
|
Run the Task 2 command again. Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Services/Portfolio tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs
|
|
git commit -m "feat(web): define portfolio contracts and validation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add Local Cover Storage Behind An S3-Ready Interface
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs`
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs`
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs`
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs`
|
|
- Modify: `src/GmRelay.Web/Program.cs`
|
|
- Modify: `src/GmRelay.Web/appsettings.Development.json`
|
|
- Modify: `src/GmRelay.Web/Dockerfile`
|
|
- Modify: `.env.example`
|
|
- Modify: `compose.yaml`
|
|
|
|
- [ ] **Step 1: Write failing storage tests**
|
|
|
|
Cover these cases with a temporary directory:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey()
|
|
{
|
|
var storage = CreateStorage();
|
|
await using var stream = new MemoryStream(
|
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);
|
|
|
|
var result = await storage.SaveAsync(stream, "image/png");
|
|
|
|
Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal);
|
|
Assert.StartsWith("/portfolio-covers/", storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal);
|
|
Assert.True(File.Exists(Path.Combine(storagePath, result.StorageKey)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("image/jpeg")]
|
|
[InlineData("image/png")]
|
|
[InlineData("image/webp")]
|
|
public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType)
|
|
{
|
|
var storage = CreateStorage();
|
|
await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => storage.SaveAsync(stream, contentType));
|
|
}
|
|
```
|
|
|
|
Also test a stream larger than `LocalPortfolioCoverStorage.MaxBytes`, invalid delete keys such as `../escape.png`, valid delete, JPEG signature, and WebP `RIFF....WEBP` signature.
|
|
|
|
Add source-contract wiring tests:
|
|
|
|
```csharp
|
|
Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal);
|
|
Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal);
|
|
Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal);
|
|
Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal);
|
|
Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
|
Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
|
Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal);
|
|
```
|
|
|
|
- [ ] **Step 2: Run storage tests to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests"
|
|
```
|
|
|
|
Expected: FAIL because storage types do not exist.
|
|
|
|
- [ ] **Step 3: Implement cover storage**
|
|
|
|
Define:
|
|
|
|
```csharp
|
|
public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);
|
|
|
|
public interface IPortfolioCoverStorage
|
|
{
|
|
Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default);
|
|
Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
|
|
string GetPublicPath(string storageKey);
|
|
}
|
|
|
|
public sealed class PortfolioCoverStorageOptions
|
|
{
|
|
public const string SectionName = "PortfolioCovers";
|
|
public string StoragePath { get; set; } = "";
|
|
}
|
|
```
|
|
|
|
Implement `LocalPortfolioCoverStorage` with:
|
|
|
|
- `public const long MaxBytes = 5 * 1024 * 1024;`
|
|
- normalized extensions `.jpg`, `.png`, `.webp`;
|
|
- signature checks: JPEG `FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, WebP `RIFF` plus `WEBP`;
|
|
- generated key `$"{Guid.NewGuid():N}{extension}"`;
|
|
- safe key regex `^[a-f0-9]{32}\.(jpg|png|webp)$`;
|
|
- temporary file write, validation before final `File.Move`;
|
|
- cleanup of the temporary file in `finally`;
|
|
- public path `/portfolio-covers/{Uri.EscapeDataString(storageKey)}`.
|
|
|
|
In `PortfolioCoverStorageExtensions.cs`, add:
|
|
|
|
```csharp
|
|
public static IServiceCollection AddPortfolioCoverStorage(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
|
|
public static WebApplication UsePortfolioCoverFiles(this WebApplication app)
|
|
```
|
|
|
|
`AddPortfolioCoverStorage` configures `PortfolioCoverStorageOptions` and registers `IPortfolioCoverStorage`. `UsePortfolioCoverFiles` resolves relative paths against `app.Environment.ContentRootPath`, creates the directory, and attaches `UseStaticFiles` with `PhysicalFileProvider`, request path `/portfolio-covers`, known image extensions only, and immutable cache headers.
|
|
|
|
- [ ] **Step 4: Register configuration, static delivery, and Docker volume**
|
|
|
|
In `Program.cs`:
|
|
|
|
```csharp
|
|
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
|
|
```
|
|
|
|
After security headers and before authentication, add:
|
|
|
|
```csharp
|
|
app.UsePortfolioCoverFiles();
|
|
```
|
|
|
|
In development settings add:
|
|
|
|
```json
|
|
"PortfolioCovers": {
|
|
"StoragePath": "../../artifacts/portfolio-covers"
|
|
}
|
|
```
|
|
|
|
In `compose.yaml`, mount:
|
|
|
|
```yaml
|
|
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
|
```
|
|
|
|
and:
|
|
|
|
```yaml
|
|
- portfolio_covers:/app/portfolio-covers
|
|
```
|
|
|
|
Declare:
|
|
|
|
```yaml
|
|
portfolio_covers:
|
|
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
|
```
|
|
|
|
Document `PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers` in `.env.example`.
|
|
|
|
In `src/GmRelay.Web/Dockerfile`, create and chown both runtime directories before `USER $APP_UID`:
|
|
|
|
```dockerfile
|
|
RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \
|
|
&& chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers
|
|
```
|
|
|
|
- [ ] **Step 5: Run storage tests and build to verify GREEN**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests"
|
|
dotnet build src/GmRelay.Web/GmRelay.Web.csproj
|
|
```
|
|
|
|
Expected: PASS and build succeeds with zero warnings.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Services/Portfolio/Covers src/GmRelay.Web/Program.cs src/GmRelay.Web/appsettings.Development.json src/GmRelay.Web/Dockerfile .env.example compose.yaml tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs
|
|
git commit -m "feat(web): add local portfolio cover storage"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Implement Portfolio Persistence
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/PortfolioService.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs`
|
|
- Modify: `src/GmRelay.Web/Program.cs`
|
|
|
|
- [ ] **Step 1: Write failing SQL source-contract tests**
|
|
|
|
Assert that `PortfolioService.cs` contains:
|
|
|
|
```csharp
|
|
Assert.Contains("portfolio_games", source, StringComparison.Ordinal);
|
|
Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal);
|
|
Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal);
|
|
Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal);
|
|
Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal);
|
|
Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal);
|
|
Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal);
|
|
Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal);
|
|
Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal);
|
|
```
|
|
|
|
Add scoped assertions against the public-master query:
|
|
|
|
```csharp
|
|
Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal);
|
|
```
|
|
|
|
Add scoped assertions against the public-club query:
|
|
|
|
```csharp
|
|
Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal);
|
|
```
|
|
|
|
Add a regression assertion by reading `SessionService.cs`:
|
|
|
|
```csharp
|
|
Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal);
|
|
```
|
|
|
|
- [ ] **Step 2: Run source-contract tests to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests"
|
|
```
|
|
|
|
Expected: FAIL because `PortfolioService.cs` does not exist.
|
|
|
|
- [ ] **Step 3: Implement public reads**
|
|
|
|
Create `PortfolioService(NpgsqlDataSource dataSource, IPortfolioCoverStorage coverStorage) : IPortfolioStore`.
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
GetPublicPortfolioGamesForMasterAsync(string masterSlug)
|
|
GetPublicPortfolioGamesForClubAsync(string clubSlug)
|
|
GetPublicPortfolioGameBySlugAsync(string slug)
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Filter `portfolio_games.is_public = true`.
|
|
- Master query joins `portfolio_game_masters` and public `master_profiles` by slug but does not require `game_groups.public_schedule_enabled`.
|
|
- Club query joins `game_groups` and requires `public_schedule_enabled = true` plus public club slug.
|
|
- Detail query returns club name and slug only when the club page is public.
|
|
- Detail query loads selected public masters separately.
|
|
- Detail query loads only consented reviews with `moderation_status = 'Approved'`.
|
|
- Convert `cover_storage_key` to a public URL with `coverStorage.GetPublicPath`.
|
|
- Public DTOs never carry private UUIDs.
|
|
|
|
- [ ] **Step 4: Implement protected reads and writes**
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
GetPortfolioGamesForGroupAsync
|
|
GetPortfolioGameGroupIdAsync
|
|
GetPortfolioGameForManagementAsync
|
|
GetEligibleCompletedSessionsAsync
|
|
GetPortfolioMasterOptionsAsync
|
|
CreatePortfolioDraftAsync
|
|
UpdatePortfolioDraftAsync
|
|
SetPortfolioCoverAsync
|
|
DeletePortfolioGameAsync
|
|
SetPortfolioPublicationAsync
|
|
ModeratePortfolioReviewAsync
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Draft creation optionally links one session only if it belongs to the same group, is in the past, and is not linked elsewhere.
|
|
- Update runs in one transaction, locks the portfolio row, updates scalar fields, unpublishes the card before replacing required child links, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club.
|
|
- Cover replacement returns the prior storage key after the database update.
|
|
- Delete returns the cover key after deleting the row.
|
|
- Publishing locks the row and verifies slug, description, cover key, one or more linked sessions, every linked session has `scheduled_at < now()`, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. The deferred database guard is a backstop for direct SQL and concurrent changes.
|
|
- Unpublishing only sets `is_public = false`.
|
|
- Moderation accepts `Approved`, `Rejected`, or `Hidden`, stores moderator ID and timestamp, and scopes the review to the managed adventure.
|
|
|
|
- [ ] **Step 5: Implement authenticated review methods**
|
|
|
|
Implement:
|
|
|
|
```csharp
|
|
GetReviewSubmissionStateAsync
|
|
SubmitPortfolioReviewAsync
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Resolve linked player identities using the same `player_links` direction as `SessionService.ResolveEffectivePlayerIdAsync`.
|
|
- Eligible means the public adventure has at least one linked past session with a matching `session_participants.player_id`, `sp.is_gm = false`, and `sp.registration_status = 'Active'`.
|
|
- Existing review returns `AlreadySubmitted`.
|
|
- Missing eligible participation returns `Ineligible`.
|
|
- Insert starts with `Pending`, stores trimmed text and the display-name snapshot, and uses `ON CONFLICT ... DO NOTHING` to reject duplicates.
|
|
|
|
- [ ] **Step 6: Register portfolio store**
|
|
|
|
In `Program.cs` add:
|
|
|
|
```csharp
|
|
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and build to verify GREEN**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests"
|
|
dotnet build src/GmRelay.Web/GmRelay.Web.csproj
|
|
```
|
|
|
|
Expected: PASS and build succeeds with zero warnings.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Services/Portfolio/PortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs
|
|
git commit -m "feat(web): add portfolio persistence"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add Authorized Portfolio Orchestration
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs`
|
|
- Modify: `src/GmRelay.Web/Program.cs`
|
|
|
|
- [ ] **Step 1: Write failing authorization tests**
|
|
|
|
Use small fake implementations of `IPortfolioStore`, `ISessionStore`, and `IPortfolioCoverStorage`.
|
|
|
|
Cover:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
|
|
{
|
|
var service = CreateService(isManager: true);
|
|
var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);
|
|
Assert.Equal(draftId, created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
|
{
|
|
var service = CreateService(isManager: false);
|
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
|
() => service.CreateDraftForCurrentUserAsync(groupId, null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
|
|
{
|
|
var service = CreateService(isManager: true, oldStorageKey: "old.png");
|
|
await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png");
|
|
Assert.Contains("old.png", fakeStorage.DeletedKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
|
|
{
|
|
var service = CreateService(isManager: true, throwOnSetCover: true);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png"));
|
|
Assert.Contains("new.png", fakeStorage.DeletedKeys);
|
|
}
|
|
```
|
|
|
|
Also test: unauthorized editor read, unauthorized update, unauthorized moderation, delete cleanup, anonymous review state, review body normalization, slug normalization, publication call, and moderator effective-player resolution.
|
|
|
|
- [ ] **Step 2: Run authorization tests to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests"
|
|
```
|
|
|
|
Expected: FAIL because `AuthorizedPortfolioService` does not exist.
|
|
|
|
- [ ] **Step 3: Implement authorized wrapper**
|
|
|
|
Create:
|
|
|
|
```csharp
|
|
public sealed class AuthorizedPortfolioService(
|
|
IPortfolioStore portfolioStore,
|
|
ISessionStore sessionStore,
|
|
IPortfolioCoverStorage coverStorage,
|
|
IHttpContextAccessor httpContextAccessor)
|
|
```
|
|
|
|
Implement management methods:
|
|
|
|
```csharp
|
|
GetPortfolioGamesForCurrentUserAsync(Guid groupId)
|
|
GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId)
|
|
GetCompletedSessionsForCurrentUserAsync(Guid groupId)
|
|
CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId)
|
|
UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update)
|
|
ReplaceCoverForCurrentUserAsync(Guid portfolioGameId, Stream content, string contentType, CancellationToken cancellationToken = default)
|
|
DeleteForCurrentUserAsync(Guid portfolioGameId)
|
|
SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic)
|
|
ModerateReviewForCurrentUserAsync(Guid portfolioGameId, Guid reviewId, string moderationStatus)
|
|
```
|
|
|
|
Implement review methods:
|
|
|
|
```csharp
|
|
GetReviewSubmissionStateForCurrentUserAsync(string slug)
|
|
SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Every management method checks `ISessionStore.IsGroupManagerAsync`.
|
|
- `GetCompletedSessionsForCurrentUserAsync` returns `IPortfolioStore.GetEligibleCompletedSessionsAsync(groupId, null)` only after the same manager check.
|
|
- Resolve the owning group through `GetPortfolioGameGroupIdAsync` before loading private editor data or applying any ID-scoped mutation.
|
|
- `UpdateDraftForCurrentUserAsync` applies `PortfolioValidation` to title, slug, description, and format.
|
|
- Reject review submission unless the consent checkbox is true.
|
|
- Cover replacement stores the new cover first, updates the database second, deletes the old cover only after the swap, and cleans up the new cover when persistence fails.
|
|
- Delete removes the database row first and deletes the cover second.
|
|
|
|
- [ ] **Step 4: Register scoped service**
|
|
|
|
In `Program.cs`:
|
|
|
|
```csharp
|
|
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests and build to verify GREEN**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests"
|
|
dotnet build src/GmRelay.Web/GmRelay.Web.csproj
|
|
```
|
|
|
|
Expected: PASS and build succeeds with zero warnings.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs
|
|
git commit -m "feat(web): authorize portfolio management and reviews"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add Protected Portfolio Management UI
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Components/Pages/PortfolioEditor.razor`
|
|
- Create: `src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor`
|
|
- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs`
|
|
- Modify: `src/GmRelay.Web/Components/Pages/GroupDetails.razor`
|
|
- Modify: `src/GmRelay.Web/Components/Pages/SessionHistory.razor`
|
|
- Modify: `src/GmRelay.Web/wwwroot/app.css`
|
|
|
|
- [ ] **Step 1: Write failing protected-page source tests**
|
|
|
|
Assert:
|
|
|
|
```csharp
|
|
Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal);
|
|
Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal);
|
|
Assert.Contains("InputFile", editor, StringComparison.Ordinal);
|
|
Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal);
|
|
Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal);
|
|
Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal);
|
|
Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal);
|
|
Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal);
|
|
Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal);
|
|
Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
|
Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
|
Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal);
|
|
Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal);
|
|
```
|
|
|
|
- [ ] **Step 2: Run page tests to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"
|
|
```
|
|
|
|
Expected: FAIL because protected portfolio UI is absent.
|
|
|
|
- [ ] **Step 3: Extend group management page**
|
|
|
|
Inject `AuthorizedPortfolioService`. Load summaries after the existing group authorization succeeds. Add a section with:
|
|
|
|
- heading `Проведённые приключения`;
|
|
- create button calling `CreateDraftForCurrentUserAsync(GroupId, null)` and navigating to `/portfolio/manage/{id}`;
|
|
- link to `/group/{GroupId}/completed`;
|
|
- rows for title, draft/public badge, linked-session count, GM count, pending-review count, and edit link.
|
|
|
|
- [ ] **Step 4: Add completed-session list**
|
|
|
|
Create `GroupCompletedSessions.razor`:
|
|
|
|
- authorized route `/group/{GroupId:guid}/completed`;
|
|
- load rows through `GetCompletedSessionsForCurrentUserAsync`;
|
|
- show past session title and Moscow date;
|
|
- provide history links;
|
|
- provide `Добавить в портфолио` buttons calling `CreateDraftForCurrentUserAsync(GroupId, session.Id)` and navigating to `/portfolio/manage/{id}`;
|
|
- render a compact empty state when the list is empty.
|
|
|
|
- [ ] **Step 5: Add completed-session quick action**
|
|
|
|
In `SessionHistory.razor`, inject `AuthorizedPortfolioService`. If the loaded session has `ScheduledAt < DateTime.UtcNow`, render `Добавить в портфолио`. On click call:
|
|
|
|
```csharp
|
|
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId);
|
|
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
|
```
|
|
|
|
- [ ] **Step 6: Add protected editor**
|
|
|
|
Create `PortfolioEditor.razor`:
|
|
|
|
- authorized route `/portfolio/manage/{PortfolioGameId:guid}`;
|
|
- load editor via `GetPortfolioGameForCurrentUserAsync`;
|
|
- edit title, slug, description, system, and format;
|
|
- render checkbox lists for completed sessions and GMs;
|
|
- save through `UpdateDraftForCurrentUserAsync`;
|
|
- upload one `IBrowserFile` with `OpenReadStream(LocalPortfolioCoverStorage.MaxBytes)` and `ReplaceCoverForCurrentUserAsync`;
|
|
- publish/unpublish through `SetPublicationForCurrentUserAsync`;
|
|
- delete through `DeleteForCurrentUserAsync`;
|
|
- render moderation rows and buttons `Одобрить`, `Отклонить`, `Скрыть`.
|
|
|
|
- [ ] **Step 7: Add protected UI styles**
|
|
|
|
Add `.portfolio-management-list`, `.portfolio-editor-grid`, `.portfolio-option-list`, `.portfolio-review-moderation`, and mobile layout rules to `app.css`.
|
|
|
|
- [ ] **Step 8: Run Task 6 tests and build to verify GREEN**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"
|
|
dotnet build src/GmRelay.Web/GmRelay.Web.csproj
|
|
```
|
|
|
|
Expected: PASS and build succeeds with zero warnings.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Components/Pages/PortfolioEditor.razor src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor src/GmRelay.Web/Components/Pages/GroupDetails.razor src/GmRelay.Web/Components/Pages/SessionHistory.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs
|
|
git commit -m "feat(web): add portfolio management UI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Add Public Portfolio Pages And Review Form
|
|
|
|
**Files:**
|
|
- Create: `src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor`
|
|
- Create: `src/GmRelay.Web/Components/Pages/PublicPortfolio.razor`
|
|
- Modify: `src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor`
|
|
- Modify: `src/GmRelay.Web/Components/Pages/PublicClub.razor`
|
|
- Modify: `src/GmRelay.Web/wwwroot/app.css`
|
|
- Modify: `tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs`
|
|
|
|
- [ ] **Step 1: Add failing public-page source tests**
|
|
|
|
Assert:
|
|
|
|
```csharp
|
|
Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal);
|
|
Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal);
|
|
Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal);
|
|
Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal);
|
|
```
|
|
|
|
- [ ] **Step 2: Run public-page tests to verify RED**
|
|
|
|
Run the Task 6 page-test command. Expected: FAIL on missing public portfolio page and card grid.
|
|
|
|
- [ ] **Step 3: Add reusable public card grid**
|
|
|
|
Create `PortfolioCardGrid.razor` with parameter:
|
|
|
|
```csharp
|
|
[Parameter, EditorRequired]
|
|
public IReadOnlyList<PublicPortfolioCard> Games { get; set; } = [];
|
|
```
|
|
|
|
Each card renders cover, title, completion date, optional system/format badges, and `/portfolio/{Slug}` link.
|
|
|
|
- [ ] **Step 4: Extend public GM and club pages**
|
|
|
|
- Inject `IPortfolioStore`.
|
|
- Load master cards with `GetPublicPortfolioGamesForMasterAsync(Slug.Trim())`.
|
|
- Load club cards with `GetPublicPortfolioGamesForClubAsync(Slug.Trim())`.
|
|
- Render `PortfolioCardGrid` below existing upcoming-session content when cards exist.
|
|
- Keep the public club portfolio tied to the existing public-club route; keep GM portfolio independent from club visibility.
|
|
|
|
- [ ] **Step 5: Add public portfolio detail and conditional review form**
|
|
|
|
Create `PublicPortfolio.razor`:
|
|
|
|
- load sanitized detail with `GetPublicPortfolioGameBySlugAsync`;
|
|
- load current-user submission state through `AuthorizedPortfolioService`;
|
|
- render cover hero, description, completion date, system, format, optional club link, GM links, and approved reviews;
|
|
- for `Eligible`, show textarea and required consent checkbox;
|
|
- for `AlreadySubmitted`, show `Отзыв отправлен на модерацию`;
|
|
- for `Ineligible`, show a short non-sensitive explanation;
|
|
- for `RequiresAuthentication`, show sign-in link;
|
|
- submit through `SubmitReviewForCurrentUserAsync`.
|
|
|
|
- [ ] **Step 6: Add public styles**
|
|
|
|
Add `.portfolio-grid`, `.portfolio-card`, `.portfolio-card-cover`, `.portfolio-cover-hero`, `.portfolio-review-list`, `.portfolio-review-card`, and responsive rules to `app.css`.
|
|
|
|
- [ ] **Step 7: Run page tests and build to verify GREEN**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"
|
|
dotnet build src/GmRelay.Web/GmRelay.Web.csproj
|
|
```
|
|
|
|
Expected: PASS and build succeeds with zero warnings.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```powershell
|
|
git add src/GmRelay.Web/Components/Portfolio src/GmRelay.Web/Components/Pages/PublicPortfolio.razor src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor src/GmRelay.Web/Components/Pages/PublicClub.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs
|
|
git commit -m "feat(web): publish completed game portfolios"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Update Documentation And Release Version
|
|
|
|
**Files:**
|
|
- Modify: `README.md`
|
|
- Modify: `docs/c4-system-context.md`
|
|
- Modify: `Directory.Build.props`
|
|
- Modify: `compose.yaml`
|
|
- Modify: `.gitea/workflows/deploy.yml`
|
|
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
|
- Modify: `tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs`
|
|
|
|
- [ ] **Step 1: Update version regression test first**
|
|
|
|
Change the expected UI version in `CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion` from `v3.5.1` to:
|
|
|
|
```csharp
|
|
Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
|
|
```
|
|
|
|
- [ ] **Step 2: Run version test to verify RED**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~NavMenu_ShouldExposeCurrentProjectVersion"
|
|
```
|
|
|
|
Expected: FAIL because `NavMenu.razor` still contains `v3.5.1`.
|
|
|
|
- [ ] **Step 3: Synchronize version `3.6.0`**
|
|
|
|
Update:
|
|
|
|
- `Directory.Build.props`: `<Version>3.6.0</Version>`
|
|
- `compose.yaml`: `gmrelay-bot`, `gmrelay-discord-bot`, and `gmrelay-web` image tags
|
|
- `.gitea/workflows/deploy.yml`: `VERSION: 3.6.0`
|
|
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v3.6.0`
|
|
- `README.md`: current version `v3.6.0`
|
|
|
|
- [ ] **Step 4: Update user-facing documentation**
|
|
|
|
In `README.md` document:
|
|
|
|
- completed adventure portfolios;
|
|
- `/portfolio/{slug}`;
|
|
- participant-submitted moderated reviews;
|
|
- cover uploads stored in `portfolio_covers`;
|
|
- optional `PORTFOLIO_COVERS_VOLUME_NAME`.
|
|
|
|
In `docs/c4-system-context.md` document:
|
|
|
|
- public portfolio pages and player review submission;
|
|
- portfolio tables in PostgreSQL;
|
|
- `PortfolioService`, `AuthorizedPortfolioService`, and `IPortfolioCoverStorage`;
|
|
- persistent `portfolio_covers` volume and future S3 replacement boundary.
|
|
|
|
- [ ] **Step 5: Run version test to verify GREEN**
|
|
|
|
Run the Task 8 version-test command again. Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```powershell
|
|
git add README.md docs/c4-system-context.md Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs
|
|
git commit -m "docs: document portfolio release and bump version to 3.6.0"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Verify The Integrated Feature
|
|
|
|
**Files:**
|
|
- No source changes unless verification exposes a defect.
|
|
|
|
- [ ] **Step 1: Run the full test suite**
|
|
|
|
```powershell
|
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 2: Run the full build**
|
|
|
|
```powershell
|
|
dotnet build
|
|
```
|
|
|
|
Expected: build succeeds with zero warnings and zero errors.
|
|
|
|
- [ ] **Step 3: Run formatting verification**
|
|
|
|
```powershell
|
|
dotnet format --verify-no-changes --verbosity diagnostic
|
|
```
|
|
|
|
Expected: exit code `0`.
|
|
|
|
- [ ] **Step 4: Check version synchronization**
|
|
|
|
```powershell
|
|
rg -n "3\.5\.1|3\.6\.0" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md
|
|
```
|
|
|
|
Expected: release references use `3.6.0`; no required release file contains `3.5.1`.
|
|
|
|
- [ ] **Step 5: Start the local app and visually inspect with Browser**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
|
|
```
|
|
|
|
Use the in-app Browser plugin to inspect:
|
|
|
|
- public GM profile portfolio cards;
|
|
- public club portfolio cards;
|
|
- `/portfolio/{slug}` detail page;
|
|
- eligible review form and consent checkbox;
|
|
- protected editor layout;
|
|
- mobile-width responsive layout.
|
|
|
|
- [ ] **Step 6: Request code review**
|
|
|
|
Dispatch a review subagent focused on:
|
|
|
|
- privacy of public DTOs and Razor output;
|
|
- SQL authorization and cross-club boundaries;
|
|
- cover-storage path safety and cleanup;
|
|
- review eligibility and moderation;
|
|
- unchanged `/showcase` future-session behavior;
|
|
- version synchronization.
|
|
|
|
- [ ] **Step 7: Apply review fixes and repeat verification**
|
|
|
|
Repeat Steps 1-4 after any change.
|
|
|
|
---
|
|
|
|
## Execution Order And Ownership
|
|
|
|
Execute tasks sequentially because later tasks depend on earlier contracts:
|
|
|
|
1. Schema
|
|
2. Contracts and validation
|
|
3. Cover storage
|
|
4. Portfolio persistence
|
|
5. Authorized orchestration
|
|
6. Protected UI
|
|
7. Public UI
|
|
8. Documentation and version
|
|
9. Integrated verification
|
|
|
|
For subagent execution, assign one fresh worker per task. Workers must not revert edits from earlier tasks. Use separate spec-compliance and code-quality review agents after each task as required by `superpowers:subagent-driven-development`.
|