From 991c7e1965085499029ea7a8a6e043c286c37948 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 14:16:12 +0300 Subject: [PATCH 01/31] docs: specify completed game portfolio --- ...6-05-30-completed-game-portfolio-design.md | 404 ++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md new file mode 100644 index 0000000..a18db71 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -0,0 +1,404 @@ +# Completed Game Portfolio - Design Spec + +> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера + +--- + +## Goal + +Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games. + +--- + +## Product Decisions + +- A portfolio item is an independent adventure entity, not a flag on one session. +- One adventure can reference multiple completed sessions from the same club. +- Reviews are submitted by authenticated players, not entered manually by a GM. +- A player can review an adventure after being registered for at least one linked completed session. +- Each player can submit one review per adventure. +- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it. +- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links. +- Adventure visibility in a public GM profile does not depend on club-page visibility. +- The public club page shows its portfolio block only when that club page is enabled. +- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure. +- Creation is available from the club page and through a quick action from a completed session. +- Every published adventure has a dedicated public page at `/portfolio/{slug}`. +- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables. + +--- + +## Architecture + +Add a portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters. + +The protected management flow is exposed through `AuthorizedSessionService`, which reuses the existing owner/co-GM group authorization model. Public reads and authenticated review submission are exposed through `ISessionStore` and `SessionService`. + +Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields. + +--- + +## Data Model + +### Migration V029 + +Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`. + +### `portfolio_games` + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | `UUID` | primary key, generated | Adventure identifier | +| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club | +| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment | +| `title` | `VARCHAR(255)` | not null | Adventure title | +| `description` | `TEXT` | nullable for drafts | Public description | +| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key | +| `system` | `VARCHAR(50)` | nullable | Game system | +| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format | +| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date | +| `is_public` | `BOOLEAN` | not null, default false | Public visibility | +| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp | +| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp | +| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp | + +Constraints and indexes: + +```sql +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 +)) +``` + +- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`. +- Index on `(group_id, completed_at DESC)`. +- Partial public index on `(completed_at DESC)` where `is_public = true`. + +Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. + +### `portfolio_game_sessions` + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure | +| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session | + +Primary key: `(portfolio_game_id, session_id)`. + +The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. A session belongs to at most one portfolio adventure. + +### `portfolio_game_masters` + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure | +| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM | + +Primary key: `(portfolio_game_id, player_id)`. + +Add an index on `(player_id, portfolio_game_id)` for public GM profile reads. + +### `portfolio_game_reviews` + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | `UUID` | primary key, generated | Review identifier | +| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure | +| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference | +| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot | +| `body` | `TEXT` | not null | Review text | +| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp | +| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state | +| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference | +| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp | +| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp | +| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp | + +Constraints and indexes: + +```sql +CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')) +UNIQUE (portfolio_game_id, author_player_id) +``` + +- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`. +- Partial moderation index on `(created_at)` where `moderation_status = 'Pending'`. + +--- + +## Cover Storage + +### Contract + +Add a small storage abstraction: + +```csharp +public interface IPortfolioCoverStorage +{ + Task SaveAsync( + Stream content, + string contentType, + CancellationToken cancellationToken = default); + + Task DeleteIfExistsAsync( + string storageKey, + CancellationToken cancellationToken = default); + + string GetPublicPath(string storageKey); +} +``` + +`PortfolioCoverUploadResult` carries the generated storage key and normalized content type. + +### Local Implementation + +- Store covers below a configured `PortfolioCovers:StoragePath`. +- Mount that path from a dedicated Docker volume, `portfolio_covers`. +- Serve files through a dedicated `/portfolio-covers/{storageKey}` route. +- Generate random names. Never use the uploaded filename as the storage key. +- Accept `image/jpeg`, `image/png`, and `image/webp`. +- Limit uploads to 5 MiB. +- Validate file signatures server-side before writing the final file. +- Write to a temporary file, validate, then atomically move into place. +- On successful replacement, delete the old file. +- On database failure after upload, delete the newly uploaded file. +- Deleting an adventure deletes its current cover after successful database deletion. + +The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key. + +--- + +## Service Contracts + +Add sanitized DTOs to `ISessionStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links. + +Representative contracts: + +```csharp +public sealed record PublicPortfolioGame( + string Slug, + string Title, + string Description, + string CoverPath, + string? System, + string? Format, + DateTime CompletedAt, + string? ClubName, + string? ClubSlug, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PublicPortfolioMaster(string Slug, string DisplayName); + +public sealed record PublicPortfolioReview( + string AuthorDisplayName, + string Body, + DateTime CreatedAt); +``` + +Protected DTOs may carry IDs needed for editing and moderation. + +### Public Reads + +- Load one public adventure by slug for `/portfolio/{slug}`. +- Load public adventures for a public GM profile regardless of club-page visibility. +- Load public adventures for a public club page only when the club page is enabled. +- Return only reviews with explicit consent and `Approved` moderation state. + +### Protected Management + +Through `AuthorizedSessionService`: + +- Load draft and published adventure cards for a managed club. +- Load eligible completed sessions for a managed club. +- Create a draft, optionally preselecting one completed session from the quick action. +- Update title, slug, description, system, format, linked sessions, and displayed GMs. +- Upload and replace the cover. +- Publish or unpublish a card. +- Load pending and historical reviews for moderation. +- Approve, reject, or hide a review. + +All management operations require the current user to be an owner or co-GM of the owning club. + +### Review Submission + +An authenticated user can submit a review from `/portfolio/{slug}` only when: + +- The adventure is public. +- The user explicitly checks publication consent. +- The user is registered in `session_participants` for at least one linked session. +- The linked session is in the past. +- The user has not submitted a review for this adventure before. + +The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`. + +--- + +## User Interface + +### Protected Club Page + +Extend `GroupDetails.razor` with a completed-adventures section: + +- List draft and published portfolio cards. +- Show title, publication state, linked-session count, displayed-GM count, and review moderation count. +- Provide a create action and edit links. + +### Completed Session Quick Action + +Extend session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected. + +### Adventure Editor + +Add a protected editor page: + +- Title and public slug. +- Description. +- System and format. +- Multi-select of completed sessions from the same club. +- Multi-select of displayed GMs. +- Cover upload and replacement. +- Draft save and publish/unpublish actions. +- Review moderation list with approve, reject, and hide actions. + +The editor surfaces validation errors without publishing partial data. + +### Public GM Profile + +Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility. + +### Public Club Page + +Extend `/club/{slug}` with the same compact cards when the public club page is enabled. + +### Public Adventure Page + +Add `/portfolio/{slug}`: + +- Cover hero. +- Title, description, completion date, system, and format. +- Optional public club link. +- Public links to selected GM profiles. +- Approved reviews with display-name snapshots. +- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox. +- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message. +- For an anonymous visitor: a sign-in prompt instead of the form. + +--- + +## Privacy And Security + +- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths. +- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename. +- Random storage keys prevent filename guessing and path traversal. +- Review text is rendered as encoded text through normal Razor rendering. +- Authorization is checked in the service layer for every management operation. +- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization. +- The `/showcase` query keeps its current future-session condition and does not include completed adventures. + +--- + +## Docker And Configuration + +Add: + +```yaml +services: + web: + environment: + - "PortfolioCovers__StoragePath=/app/portfolio-covers" + volumes: + - portfolio_covers:/app/portfolio-covers + +volumes: + portfolio_covers: + name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers} +``` + +Development configuration uses a local directory under the application content root or an explicitly configured path. + +--- + +## Documentation + +Update: + +- `README.md` with public portfolio capability and local cover-storage configuration. +- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume. + +--- + +## Testing Strategy + +Follow TDD for production changes. + +### Schema And Contracts + +- Migration source-contract tests assert the four new tables, constraints, and indexes. +- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. +- Existing showcase tests continue to assert the future-session catalog boundary. + +### Authorization And Eligibility + +- Owner and co-GM can manage a club adventure. +- A manager of another club cannot manage it. +- Only registered players from linked past sessions can submit. +- A registered player can submit only once. +- Consent is required. +- A new review is pending and not public. +- Only approved reviews are returned publicly. + +### Cover Storage + +- Accept valid JPEG, PNG, and WebP signatures. +- Reject unsupported types, mismatched signatures, oversized files, and unsafe names. +- Replacement deletes the old file only after the new file is stored. +- Cleanup removes a newly uploaded file when persistence fails. + +### UI Source Contracts + +- Protected club and session-history pages expose management entry points. +- Public GM and club pages render compact portfolio sections. +- The public adventure page renders approved reviews and the conditional review form. +- CSS defines responsive portfolio cards, cover hero, editor layout, and review states. + +### Regression + +- Run the full test suite. +- Run `dotnet build`. +- Run `dotnet format --verify-no-changes`. +- Visually inspect the protected editor and public portfolio pages in the browser. + +--- + +## Version Bump + +Issue label: `type:feature` -> minor bump. + +Current: `3.5.1` -> Next: `3.6.0`. + +Synchronize: + +- `Directory.Build.props` +- `compose.yaml` (`bot`, `discord`, and `web` image tags) +- `.gitea/workflows/deploy.yml` (`VERSION`) +- `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +--- + +## Acceptance Criteria Mapping + +- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description. +- [ ] A portfolio adventure can group one or more completed sessions from the same club. +- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility. +- [ ] A public club page shows portfolio cards when enabled. +- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews. +- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent. +- [ ] Reviews remain non-public until owner/co-GM moderation approves them. +- [ ] Public DTOs and HTML do not expose private identifiers. +- [ ] Uploaded covers survive container replacement through a persistent Docker volume. +- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation. +- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games. -- 2.52.0 From ac417731d64ef8800f024fd0f9ee7dd1dd7683a8 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 21:36:05 +0300 Subject: [PATCH 02/31] docs: plan completed game portfolio implementation --- .../2026-05-30-completed-game-portfolio.md | 1208 +++++++++++++++++ ...6-05-30-completed-game-portfolio-design.md | 18 +- 2 files changed, 1218 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-30-completed-game-portfolio.md diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md new file mode 100644 index 0000000..d682780 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -0,0 +1,1208 @@ +# 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/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.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 + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` +- Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql` + +- [ ] **Step 1: Write the failing migration source-contract test** + +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"); + + 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); +} +``` + +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); +} +``` + +- [ ] **Step 2: Run the migration test to verify RED** + +Run: + +```powershell +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests" +``` + +Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist. + +- [ ] **Step 3: 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 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_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 (created_at) + WHERE moderation_status = 'Pending'; +``` + +- [ ] **Step 4: Run the migration tests to verify GREEN** + +Run the Task 1 command again. Expected: PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +git commit -m "feat(data): add completed game portfolio schema" +``` + +--- + +### 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(() => PortfolioValidation.NormalizeSlug(input)); +} + +[Theory] +[InlineData("")] +[InlineData(" ")] +public void NormalizeReviewBody_ShouldRejectBlankText(string body) +{ + Assert.Throws(() => 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 Masters, + IReadOnlyList 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 Sessions, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameUpdate( + string Title, string? PublicSlug, string? Description, string? System, string? Format, + IReadOnlyCollection SessionIds, IReadOnlyCollection MasterPlayerIds); + +public enum PortfolioReviewSubmissionState +{ + RequiresAuthentication, + Ineligible, + Eligible, + AlreadySubmitted +} +``` + +Define `IPortfolioStore` with: + +```csharp +Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug); +Task> GetPublicPortfolioGamesForClubAsync(string clubSlug); +Task GetPublicPortfolioGameBySlugAsync(string slug); +Task> GetPortfolioGamesForGroupAsync(Guid groupId); +Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId); +Task GetPortfolioGameForManagementAsync(Guid portfolioGameId); +Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId); +Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId); +Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId); +Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update); +Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey); +Task 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 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( + () => 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 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, 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 past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. +- 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(); +``` + +- [ ] **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( + () => 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( + () => 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(); +``` + +- [ ] **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 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`: `3.6.0` +- `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`. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index a18db71..162adb4 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -15,7 +15,7 @@ Add a public portfolio of completed tabletop adventures. A club owner or co-GM c - A portfolio item is an independent adventure entity, not a flag on one session. - One adventure can reference multiple completed sessions from the same club. - Reviews are submitted by authenticated players, not entered manually by a GM. -- A player can review an adventure after being registered for at least one linked completed session. +- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible. - Each player can submit one review per adventure. - A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it. - Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links. @@ -30,9 +30,9 @@ Add a public portfolio of completed tabletop adventures. A club owner or co-GM c ## Architecture -Add a portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters. +Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters. -The protected management flow is exposed through `AuthorizedSessionService`, which reuses the existing owner/co-GM group authorization model. Public reads and authenticated review submission are exposed through `ISessionStore` and `SessionService`. +Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads. Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields. @@ -173,7 +173,7 @@ The storage key remains provider-neutral. A future S3-compatible implementation ## Service Contracts -Add sanitized DTOs to `ISessionStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links. +Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links. Representative contracts: @@ -210,7 +210,7 @@ Protected DTOs may carry IDs needed for editing and moderation. ### Protected Management -Through `AuthorizedSessionService`: +Through `AuthorizedPortfolioService`: - Load draft and published adventure cards for a managed club. - Load eligible completed sessions for a managed club. @@ -229,7 +229,7 @@ An authenticated user can submit a review from `/portfolio/{slug}` only when: - The adventure is public. - The user explicitly checks publication consent. -- The user is registered in `session_participants` for at least one linked session. +- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session. - The linked session is in the past. - The user has not submitted a review for this adventure before. @@ -245,11 +245,11 @@ Extend `GroupDetails.razor` with a completed-adventures section: - List draft and published portfolio cards. - Show title, publication state, linked-session count, displayed-GM count, and review moderation count. -- Provide a create action and edit links. +- Provide a create action, edit links, and a link to the club's completed-session list. ### Completed Session Quick Action -Extend session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected. +Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected. ### Adventure Editor @@ -320,6 +320,8 @@ volumes: Development configuration uses a local directory under the application content root or an explicitly configured path. +The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. + --- ## Documentation -- 2.52.0 From 67b8aafd97b1060e0aae2d1271a58806cc54fcc6 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 23:21:31 +0300 Subject: [PATCH 03/31] feat(data): add completed game portfolio schema --- ..._completed_game_portfolios_and_reviews.sql | 77 +++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 52 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql new file mode 100644 index 0000000..9e0df78 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -0,0 +1,77 @@ +-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews. + +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 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_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 (created_at) + WHERE moderation_status = 'Pending'; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs new file mode 100644 index 0000000..50f911c --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -0,0 +1,52 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioMigrationTests +{ + [Fact] + public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards() + { + var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); + + 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'", migration, StringComparison.Ordinal); + Assert.Contains("'Approved'", migration, StringComparison.Ordinal); + Assert.Contains("'Rejected'", migration, StringComparison.Ordinal); + Assert.Contains("'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); + } + + [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); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} -- 2.52.0 From a0040ec9fbad0d49b04db1c8a6d7e89af682107f Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 23:25:12 +0300 Subject: [PATCH 04/31] test(data): tighten portfolio moderation schema assertion --- tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 50f911c..51ce061 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -14,10 +14,7 @@ public sealed class PortfolioMigrationTests 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'", migration, StringComparison.Ordinal); - Assert.Contains("'Approved'", migration, StringComparison.Ordinal); - Assert.Contains("'Rejected'", migration, StringComparison.Ordinal); - Assert.Contains("'Hidden'", migration, StringComparison.Ordinal); + Assert.Contains("CHECK (moderation_status IN ('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); -- 2.52.0 From ed842d21958eb0fa0cc007be5197fe1f39ee9d98 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 23:37:40 +0300 Subject: [PATCH 05/31] test(data): harden portfolio migration contract --- ..._completed_game_portfolios_and_reviews.sql | 7 ++++++ .../Web/PortfolioMigrationTests.cs | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 9e0df78..1243f1c 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -68,6 +68,13 @@ CREATE TABLE portfolio_game_reviews ( 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; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 51ce061..dace6e0 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -6,6 +6,7 @@ public sealed class PortfolioMigrationTests 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); @@ -15,9 +16,23 @@ public sealed class PortfolioMigrationTests Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal); Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal); Assert.Contains("CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))", migration, StringComparison.Ordinal); - Assert.Contains("publication_consent_at", migration, StringComparison.Ordinal); + Assert.Contains("CHECK (NOT is_public OR (", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("public_slug IS NOT NULL", migration, StringComparison.Ordinal); + Assert.Contains("description IS NOT NULL", migration, StringComparison.Ordinal); + Assert.Contains("cover_storage_key IS NOT NULL", migration, StringComparison.Ordinal); + Assert.Contains("published_at IS NOT NULL", migration, StringComparison.Ordinal); + Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("REFERENCES game_groups(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); + Assert.Contains("REFERENCES sessions(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); + Assert.Contains("REFERENCES players(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); + Assert.Contains("REFERENCES players(id) ON DELETE SET NULL", migration, StringComparison.Ordinal); Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal); Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal); + Assert.Contains("WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_game_reviews_pending", migration, StringComparison.Ordinal); + Assert.Contains("WHERE moderation_status = 'Pending'", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_game_reviews_author", migration, StringComparison.Ordinal); + Assert.Contains("ix_portfolio_game_reviews_moderator", migration, StringComparison.Ordinal); } [Fact] @@ -30,6 +45,13 @@ public sealed class PortfolioMigrationTests Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase); } + private static string NormalizeSql(string sql) + { + return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Replace("( ", "(", StringComparison.Ordinal) + .Replace(" )", ")", StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); -- 2.52.0 From 5809a470b920acc10101455705576bf84eb9befa Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 09:07:47 +0300 Subject: [PATCH 06/31] test(data): scope portfolio migration assertions --- .../Web/PortfolioMigrationTests.cs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index dace6e0..72c9d27 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -12,27 +12,21 @@ public sealed class PortfolioMigrationTests 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("CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))", migration, StringComparison.Ordinal); - Assert.Contains("CHECK (NOT is_public OR (", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("public_slug IS NOT NULL", migration, StringComparison.Ordinal); - Assert.Contains("description IS NOT NULL", migration, StringComparison.Ordinal); - Assert.Contains("cover_storage_key IS NOT NULL", migration, StringComparison.Ordinal); - Assert.Contains("published_at IS NOT NULL", migration, StringComparison.Ordinal); - Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("REFERENCES game_groups(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); - Assert.Contains("REFERENCES sessions(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); - Assert.Contains("REFERENCES players(id) ON DELETE CASCADE", migration, StringComparison.Ordinal); - Assert.Contains("REFERENCES players(id) ON DELETE SET NULL", migration, StringComparison.Ordinal); - Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal); - Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal); - Assert.Contains("WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL", migration, StringComparison.Ordinal); - Assert.Contains("ix_portfolio_game_reviews_pending", migration, StringComparison.Ordinal); - Assert.Contains("WHERE moderation_status = 'Pending'", migration, StringComparison.Ordinal); - Assert.Contains("ix_portfolio_game_reviews_author", migration, StringComparison.Ordinal); - Assert.Contains("ix_portfolio_game_reviews_moderator", migration, StringComparison.Ordinal); + Assert.Contains("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))", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_games_public ON portfolio_games (completed_at DESC) WHERE is_public = true;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_author ON portfolio_game_reviews (author_player_id);", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (created_at) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } [Fact] -- 2.52.0 From d591e5ed5aee3f2899fad26998827c50d4aba3a2 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 09:20:27 +0300 Subject: [PATCH 07/31] fix(data): protect portfolio publication invariant --- .../2026-05-30-completed-game-portfolio.md | 57 ++++++++++++++++++- ...6-05-30-completed-game-portfolio-design.md | 6 +- ..._completed_game_portfolios_and_reviews.sql | 44 +++++++++++++- .../Web/PortfolioMigrationTests.cs | 12 +++- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index d682780..ca40d95 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -73,6 +73,7 @@ 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); @@ -85,6 +86,16 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now()", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters", normalizedMigration, StringComparison.Ordinal); } ``` @@ -167,6 +178,48 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); +CREATE FUNCTION unpublish_portfolio_game_without_required_links() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM 1 + FROM portfolio_games + WHERE id = OLD.portfolio_game_id + FOR UPDATE; + + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = OLD.portfolio_game_id + AND is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + ); + + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete +AFTER DELETE ON portfolio_game_sessions +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + +CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete +AFTER DELETE ON portfolio_game_masters +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_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, @@ -188,10 +241,12 @@ CREATE INDEX ix_portfolio_game_reviews_public WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL; CREATE INDEX ix_portfolio_game_reviews_pending - ON portfolio_game_reviews (created_at) + ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending'; ``` +The delete triggers retain the link-table `ON DELETE CASCADE` behavior. They lock the parent card before checking both required link sets, unpublish the card and refresh `updated_at` when either set becomes empty, preserve the first-publication `published_at`, and become harmless no-ops while the card itself or its owning club is being deleted. + - [ ] **Step 4: Run the migration tests to verify GREEN** Run the Task 1 command again. Expected: PASS. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 162adb4..84cf31f 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,6 +79,8 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. +Database triggers on `portfolio_game_sessions` and `portfolio_game_masters` protect the same invariant after link removal. After a link is deleted, the trigger locks the parent card, checks both required link sets, and sets `is_public = false` with a fresh `updated_at` when either set is empty. It deliberately preserves `published_at` as the timestamp of the first publication. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, the trigger update is a harmless no-op for the disappearing parent row. + ### `portfolio_game_sessions` | Column | Type | Constraints | Description | @@ -125,7 +127,7 @@ UNIQUE (portfolio_game_id, author_player_id) ``` - Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`. -- Partial moderation index on `(created_at)` where `moderation_status = 'Pending'`. +- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`. --- @@ -339,7 +341,7 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, constraints, and indexes. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and the link-removal trigger protection. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 1243f1c..c6aeed4 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -52,6 +52,48 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); +CREATE FUNCTION unpublish_portfolio_game_without_required_links() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM 1 + FROM portfolio_games + WHERE id = OLD.portfolio_game_id + FOR UPDATE; + + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = OLD.portfolio_game_id + AND is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters + WHERE portfolio_game_id = OLD.portfolio_game_id + ) + ); + + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete +AFTER DELETE ON portfolio_game_sessions +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); + +CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete +AFTER DELETE ON portfolio_game_masters +FOR EACH ROW +EXECUTE FUNCTION unpublish_portfolio_game_without_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, @@ -80,5 +122,5 @@ CREATE INDEX ix_portfolio_game_reviews_public WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL; CREATE INDEX ix_portfolio_game_reviews_pending - ON portfolio_game_reviews (created_at) + ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending'; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 72c9d27..dd74505 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -21,11 +21,21 @@ public sealed class PortfolioMigrationTests Assert.Contains("portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE, player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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,", normalizedMigration, StringComparison.Ordinal); Assert.Contains("moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,", normalizedMigration, 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_games_public ON portfolio_games (completed_at DESC) WHERE is_public = true;", 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_author ON portfolio_game_reviews (author_player_id);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (created_at) WHERE moderation_status = 'Pending';", 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = OLD.portfolio_game_id AND is_public = true AND (NOT EXISTS (SELECT 1 FROM portfolio_game_sessions WHERE portfolio_game_id = OLD.portfolio_game_id) OR NOT EXISTS (SELECT 1 FROM portfolio_game_masters WHERE portfolio_game_id = OLD.portfolio_game_id));", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } -- 2.52.0 From 3c1a98bcc4c01d86278125901ced5520efa81c3a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 09:46:18 +0300 Subject: [PATCH 08/31] fix(data): harden portfolio publication concurrency --- .../2026-05-30-completed-game-portfolio.md | 90 ++++--- ...6-05-30-completed-game-portfolio-design.md | 7 +- ..._completed_game_portfolios_and_reviews.sql | 73 +++-- .../Sessions/DiscordDeleteSessionHandler.cs | 17 ++ .../ListSessions/DeleteSessionHandler.cs | 16 +- .../GmRelay.Bot.Tests.csproj | 1 + .../Web/PortfolioMigrationPostgresFixture.cs | 90 +++++++ .../Web/PortfolioMigrationPostgresTests.cs | 254 ++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 12 +- .../PortfolioSessionDeletionSourceTests.cs | 60 +++++ tests/GmRelay.Bot.Tests/packages.lock.json | 107 +++++++- 11 files changed, 648 insertions(+), 79 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index ca40d95..f945d12 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -91,11 +91,12 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now()", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", 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 DELETE OR UPDATE OF portfolio_game_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.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); } ``` @@ -178,47 +179,64 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); -CREATE FUNCTION unpublish_portfolio_game_without_required_links() +CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + target_portfolio_game_id UUID; BEGIN - PERFORM 1 - FROM portfolio_games - WHERE id = OLD.portfolio_game_id - FOR UPDATE; + IF TG_TABLE_NAME = 'portfolio_games' THEN + target_portfolio_game_id := NEW.id; + ELSE + target_portfolio_game_id := OLD.portfolio_game_id; + END IF; - UPDATE portfolio_games - SET is_public = false, - updated_at = now() - WHERE id = OLD.portfolio_game_id - AND is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions - WHERE portfolio_game_id = OLD.portfolio_game_id + IF EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = target_portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = target_portfolio_game_id + ) ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters - WHERE portfolio_game_id = OLD.portfolio_game_id - ) - ); + ) 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 OLD; + RETURN NULL; END; $$; -CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete -AFTER DELETE ON portfolio_game_sessions +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); -CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete -AFTER DELETE ON portfolio_game_masters +CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links +AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW -EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); +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(), @@ -245,7 +263,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The delete triggers retain the link-table `ON DELETE CASCADE` behavior. They lock the parent card before checking both required link sets, unpublish the card and refresh `updated_at` when either set becomes empty, preserve the first-publication `published_at`, and become harmless no-ops while the card itself or its owning club is being deleted. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they reject a surviving published card when either required link set is empty. Child delete triggers do not lock or update the parent card, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. - [ ] **Step 4: Run the migration tests to verify GREEN** @@ -741,10 +759,10 @@ 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, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club. +- 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 past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. +- Publishing locks the row and verifies slug, description, cover key, one or more linked past sessions, 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. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 84cf31f..f3a4f95 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -77,9 +77,9 @@ CHECK (NOT is_public OR ( - Index on `(group_id, completed_at DESC)`. - Partial public index on `(completed_at DESC)` where `is_public = true`. -Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. +Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Database triggers on `portfolio_game_sessions` and `portfolio_game_masters` protect the same invariant after link removal. After a link is deleted, the trigger locks the parent card, checks both required link sets, and sets `is_public = false` with a fresh `updated_at` when either set is empty. It deliberately preserves `published_at` as the timestamp of the first publication. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, the trigger update is a harmless no-op for the disappearing parent row. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. The deferred guard is a database backstop and deliberately does not lock or update a parent card from a child delete trigger, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -341,7 +341,8 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and the link-removal trigger protection. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, explicit unpublish before session deletion, concurrent publish/delete ordering without deadlock, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index c6aeed4..50843d1 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -52,47 +52,64 @@ CREATE TABLE portfolio_game_masters ( CREATE INDEX ix_portfolio_game_masters_player ON portfolio_game_masters (player_id, portfolio_game_id); -CREATE FUNCTION unpublish_portfolio_game_without_required_links() +CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + target_portfolio_game_id UUID; BEGIN - PERFORM 1 - FROM portfolio_games - WHERE id = OLD.portfolio_game_id - FOR UPDATE; + IF TG_TABLE_NAME = 'portfolio_games' THEN + target_portfolio_game_id := NEW.id; + ELSE + target_portfolio_game_id := OLD.portfolio_game_id; + END IF; - UPDATE portfolio_games - SET is_public = false, - updated_at = now() - WHERE id = OLD.portfolio_game_id - AND is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions - WHERE portfolio_game_id = OLD.portfolio_game_id + IF EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + AND ( + NOT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = target_portfolio_game_id + ) + OR NOT EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = target_portfolio_game_id + ) ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters - WHERE portfolio_game_id = OLD.portfolio_game_id - ) - ); + ) 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 OLD; + RETURN NULL; END; $$; -CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete -AFTER DELETE ON portfolio_game_sessions +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 unpublish_portfolio_game_without_required_links(); +EXECUTE FUNCTION validate_public_portfolio_game_required_links(); -CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete -AFTER DELETE ON portfolio_game_masters +CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links +AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW -EXECUTE FUNCTION unpublish_portfolio_game_without_required_links(); +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(), diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index 5a83a4c..d1f6462 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -43,6 +43,23 @@ public sealed class DiscordDeleteSessionHandler( } await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await connection.ExecuteAsync( + """ + 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 + """, + new { SessionId = sessionId, GuildId = guildId }, + transaction); + var deletedRows = await connection.ExecuteAsync( """ DELETE FROM sessions s diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 94e0bdb..dc88d4d 100644 --- a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -62,7 +62,21 @@ public sealed class DeleteSessionHandler( return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0); } - // 2. Delete session + // 2. Unpublish a linked portfolio card before its required session link cascades away. + await connection.ExecuteAsync( + """ + 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 + """, + new { command.SessionId }, + transaction); + + // 3. Delete session await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); var remainingInTopic = session.ThreadId.HasValue diff --git a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj index f88c60e..215330c 100644 --- a/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj +++ b/tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs new file mode 100644 index 0000000..e80468c --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs @@ -0,0 +1,90 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace GmRelay.Bot.Tests.Web; + +[CollectionDefinition(Name)] +public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture +{ + public const string Name = "Portfolio migration PostgreSQL"; +} + +public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime +{ + private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2); + private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + + public Task InitializeAsync() + { + return container.StartAsync().WaitAsync(ContainerTimeout); + } + + public Task DisposeAsync() + { + return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout); + } + + public async Task CreateMigratedDatabaseAsync() + { + var databaseName = $"portfolio_{Guid.NewGuid():N}"; + + await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString())) + { + await adminConnection.OpenAsync().WaitAsync(ContainerTimeout); + await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection); + await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + Database = databaseName, + Timeout = 10, + CommandTimeout = 10 + }.ConnectionString; + + var migrations = GetMigrationPaths(); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync().WaitAsync(ContainerTimeout); + + foreach (var migration in migrations) + { + await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection) + { + CommandTimeout = 30 + }; + await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout); + } + + return new MigratedPortfolioDatabase(connectionString, migrations.Count); + } + + private static IReadOnlyList GetMigrationPaths() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations"); + if (Directory.Exists(migrationsDirectory)) + { + return Directory.GetFiles(migrationsDirectory, "V*.sql") + .Where(path => string.CompareOrdinal(Path.GetFileName(path), "V030__") < 0) + .OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal) + .ToArray(); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the bot migrations directory."); + } +} + +public sealed record MigratedPortfolioDatabase(string ConnectionString, int AppliedMigrationCount) +{ + public async Task OpenConnectionAsync() + { + var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10)); + return connection; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs new file mode 100644 index 0000000..9306172 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -0,0 +1,254 @@ +using Npgsql; + +namespace GmRelay.Bot.Tests.Web; + +[Collection(PortfolioMigrationPostgresCollection.Name)] +public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFixture fixture) +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10); + private static long nextLegacyId = 1000; + + [Fact] + public async Task MigrationsV001ThroughV029_ShouldApplyToPostgres17() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + + Assert.Equal(29, database.AppliedMigrationCount); + + await using var connection = await database.OpenConnectionAsync(); + Assert.Equal(4, await ExecuteScalarAsync( + connection, + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ( + 'portfolio_games', + 'portfolio_game_sessions', + 'portfolio_game_masters', + 'portfolio_game_reviews') + """)); + } + + [Theory] + [InlineData("portfolio_game_sessions")] + [InlineData("portfolio_game_masters")] + public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(string linkTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Fact] + public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM sessions WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionId)); + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.False(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(0, await ExecuteScalarAsync( + connection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var deleteConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(publishConnection, isPublic: false); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + publishConnection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + await ExecuteNonQueryAsync(deleteConnection, "SET LOCAL lock_timeout = '2s'", deleteTransaction); + + Assert.Equal(1, await ExecuteNonQueryAsync( + deleteConnection, + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + deleteTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); + + var exception = await Assert.ThrowsAsync( + () => publishTransaction.CommitAsync().WaitAsync(CommandTimeout)); + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var cardDeleteSeed = await SeedCardAsync(connection, isPublic: true); + + await ExecuteNonQueryAsync( + connection, + "DELETE FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", cardDeleteSeed.PortfolioGameId)); + + var groupDeleteSeed = await SeedCardAsync(connection, isPublic: true); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM game_groups WHERE id = @groupId", + parameters: new NpgsqlParameter("groupId", groupDeleteSeed.GroupId)); + + Assert.Equal(0, await ExecuteScalarAsync( + connection, + "SELECT COUNT(*) FROM portfolio_games WHERE id IN (@cardDeleteId, @groupDeleteId)", + parameters: + [ + new NpgsqlParameter("cardDeleteId", cardDeleteSeed.PortfolioGameId), + new NpgsqlParameter("groupDeleteId", groupDeleteSeed.PortfolioGameId) + ])); + } + + private static async Task SeedCardAsync(NpgsqlConnection connection, bool isPublic) + { + var playerId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var legacyId = Interlocked.Increment(ref nextLegacyId); + var publishedAtValue = DateTime.UtcNow.AddDays(-1); + var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) + VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); + + INSERT INTO game_groups (id, telegram_chat_id, name, gm_telegram_id, platform, external_group_id) + VALUES (@groupId, @legacyId, 'Portfolio Club', @legacyId, 'Telegram', @legacyIdText); + + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + + INSERT INTO portfolio_games ( + id, + group_id, + public_slug, + title, + description, + cover_storage_key, + is_public, + published_at) + VALUES ( + @portfolioGameId, + @groupId, + @publicSlug, + 'Completed Adventure', + 'A completed adventure.', + 'covers/example.webp', + @isPublic, + CASE WHEN @isPublic THEN @publishedAt ELSE NULL END); + + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + VALUES (@portfolioGameId, @playerId); + """, + transaction, + new NpgsqlParameter("playerId", playerId), + new NpgsqlParameter("legacyId", legacyId), + new NpgsqlParameter("legacyIdText", legacyId.ToString()), + new NpgsqlParameter("groupId", groupId), + new NpgsqlParameter("sessionId", sessionId), + new NpgsqlParameter("portfolioGameId", portfolioGameId), + new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"), + new NpgsqlParameter("isPublic", isPublic), + new NpgsqlParameter("publishedAt", publishedAt)); + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + return new PortfolioSeed(portfolioGameId, groupId, sessionId, publishedAt); + } + + private static async Task ExecuteNonQueryAsync( + NpgsqlConnection connection, + string sql, + NpgsqlTransaction? transaction = null, + params NpgsqlParameter[] parameters) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + command.Parameters.AddRange(parameters); + return await command.ExecuteNonQueryAsync().WaitAsync(CommandTimeout); + } + + private static async Task ExecuteScalarAsync( + NpgsqlConnection connection, + string sql, + NpgsqlTransaction? transaction = null, + params NpgsqlParameter[] parameters) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + command.Parameters.AddRange(parameters); + return (T)(await command.ExecuteScalarAsync().WaitAsync(CommandTimeout))!; + } + + private sealed record PortfolioSeed( + Guid PortfolioGameId, + Guid GroupId, + Guid SessionId, + DateTime PublishedAt); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index dd74505..a5d1bce 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -30,11 +30,13 @@ public sealed class PortfolioMigrationTests Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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;", 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 unpublish_portfolio_game_without_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("PERFORM 1 FROM portfolio_games WHERE id = OLD.portfolio_game_id FOR UPDATE;", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("UPDATE portfolio_games SET is_public = false, updated_at = now() WHERE id = OLD.portfolio_game_id AND is_public = true AND (NOT EXISTS (SELECT 1 FROM portfolio_game_sessions WHERE portfolio_game_id = OLD.portfolio_game_id) OR NOT EXISTS (SELECT 1 FROM portfolio_game_masters WHERE portfolio_game_id = OLD.portfolio_game_id));", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_sessions_unpublish_after_delete AFTER DELETE ON portfolio_game_sessions FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE TRIGGER trg_portfolio_game_masters_unpublish_after_delete AFTER DELETE ON portfolio_game_masters FOR EACH ROW EXECUTE FUNCTION unpublish_portfolio_game_without_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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';", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs new file mode 100644 index 0000000..f32c441 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -0,0 +1,60 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioSessionDeletionSourceTests +{ + [Fact] + public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs")); + + 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(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal), + "Linked public portfolio cards must be unpublished before deleting the session."); + } + + [Fact] + public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession() + { + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + + 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(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal), + "Discord cards must be unpublished before deleting the session."); + } + + private static string NormalizeSql(string sql) + { + return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Replace("( ", "(", StringComparison.Ordinal) + .Replace(" )", ")", StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index d350362..385f11b 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -34,6 +34,15 @@ "resolved": "5.6.7", "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.12.0, )", + "resolved": "4.12.0", + "contentHash": "LZcQu4vfcYuzzy2ENOb7giFb6WNztEEJbufsm7kGiQxjallVuzkWxrBL8LwnjlXGW939pgEZFstL5cO0R2XrBQ==", + "dependencies": { + "Testcontainers": "4.12.0" + } + }, "xunit": { "type": "Direct", "requested": "[2.9.3, )", @@ -70,6 +79,11 @@ "Npgsql": "8.0.3" } }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, "Dapper": { "type": "Transitive", "resolved": "2.1.72", @@ -94,6 +108,63 @@ "dbup-core": "6.1.1" } }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "tm2V/DpnaURbBhMQ7Z3orNR3u+H863KQuYfA/sgGjI5py07dEeV0I02f6pGrx2869KG9uNM/E96puf9i0gId2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0", + "Docker.DotNet.Enhanced.LegacyHttp": "4.2.0", + "Docker.DotNet.Enhanced.NPipe": "4.2.0", + "Docker.DotNet.Enhanced.NativeHttp": "4.2.0", + "Docker.DotNet.Enhanced.Unix": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "cQNxpdadEPdNdfjFCl9vgoCQIK3aVHRn1Qlu36aZUFpp4xHfPrk4hRPNVLR/CpobIFJ+dAt8AceTKMlCfPSccw==" + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "sfbMX1HBPUec3PEMoqlP5ak6skXclcTBmu4gG3aUJatP34J2DgvYMP13bvz/rfrjVkAhPqnIiDKiHAkBCokajg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "/ll+2ePYm1qrsMdgMO5BzCQnbfTGmPJAc9SqXEManbliVBZvEpBKHXLugx/OeEca2oC/b4RV+UNPtue5u4jAuA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "8wyYOD6VkvqRkITwsvkt3UbW/1WDl6NFypNAsIIDaMiglNRzFrQcK0nK9VUEZa6Oja8Bso3UYySDoL8qatatAA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "x0wNcbww1+p9nUfw8i+JvsSArBDGkoZ9GI2PZ1wPo85B2OiFrdzp89omounNhO2GKyaIRWAqAm5jYZyNg9EnxA==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "nMw+FHGwGZieDi7kBgpIVl+E8MzjzXeXHvMQpidLADT06fts2Gw6G+K+p0hMGv7liZULxyYiZnQ1UbE2B9NNQg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.2.0" + } + }, "Microsoft.AspNetCore.TestHost": { "type": "Transitive", "resolved": "10.0.5", @@ -341,11 +412,35 @@ "Polly.Core": "8.4.2" } }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2" + } + }, "Telegram.Bot": { "type": "Transitive", "resolved": "22.9.6.1", "contentHash": "I0eaMaETcWIhMn4uu4RGd9e6PLJOjaOG3QAcKPsTcS80H3TF6gqj3UF9NKu4ZY90ul6Y6NiWToHkg/PsvxkotA==" }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.12.0", + "contentHash": "PTZRdG1ZVkFMsFbc3cK/VUaOB5L3l4wYL+OkWAK33/cvgd/5FcmZlQ6NhMAl3PWBqYkpdWmeYmQW9U2OIXqtFA==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.2.0", + "Docker.DotNet.Enhanced.X509": "4.2.0", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -392,8 +487,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", "dbup-postgresql": "[7.0.1, )" @@ -405,8 +500,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "NetCord.Hosting": "[1.0.0-alpha.489, )", "NetCord.Hosting.Services": "[1.0.0-alpha.489, )", "NetCord.Services": "[1.0.0-alpha.489, )", @@ -437,8 +532,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[3.0.9, )", - "GmRelay.Shared": "[3.0.9, )", + "GmRelay.ServiceDefaults": "[3.5.1, )", + "GmRelay.Shared": "[3.5.1, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" } -- 2.52.0 From f7a12d14d247c36eda8ca11bad26d3d88d35fccb Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 09:56:33 +0300 Subject: [PATCH 09/31] docs: document portfolio concurrency hardening plan --- .../2026-05-30-completed-game-portfolio.md | 138 ++++++++++++++++-- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index f945d12..80cc2ca 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -61,9 +61,16 @@ **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.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: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` +- Modify: `tests/GmRelay.Bot.Tests/packages.lock.json` -- [ ] **Step 1: Write the failing migration source-contract test** +- [ ] **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: @@ -115,17 +122,90 @@ public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() } ``` -- [ ] **Step 2: Run the migration test to verify RED** +Add `PortfolioSessionDeletionSourceTests.cs`. Normalize whitespace before comparing source text and assert that both session-deletion paths explicitly unpublish linked cards before deleting the required session link: + +```csharp +[Fact] +public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession() +{ + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs")); + + 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(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal)); +} + +[Fact] +public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession() +{ + var source = NormalizeSql(await ReadRepositoryFileAsync( + "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + + 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(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(unpublish, StringComparison.Ordinal) < + source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal)); +} +``` + +- [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** + +Add the package reference: + +```xml + +``` + +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() + +[Fact] +public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() + +[Fact] +public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() +``` + +The direct-delete theory must expect PostgreSQL `23514` at commit for each required-link table. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The concurrency scenario must bound both commits with timeouts, prove there is no deadlock, and prove that an invalid public card cannot commit. 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" +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests" ``` -Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist. +Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist and the session-deletion handlers do not explicitly unpublish linked portfolio cards before deleting sessions. -- [ ] **Step 3: Add migration V029** +- [ ] **Step 4: Add migration V029** Create the migration with these exact tables and indexes: @@ -265,15 +345,53 @@ CREATE INDEX ix_portfolio_game_reviews_pending The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they reject a surviving published card when either required link set is empty. Child delete triggers do not lock or update the parent card, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. -- [ ] **Step 4: Run the migration tests to verify GREEN** +- [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** -Run the Task 1 command again. Expected: PASS. +In `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`, run this statement inside the existing transaction after authorization and before `DELETE FROM sessions`: -- [ ] **Step 5: Commit** +```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. 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 unpublish before session deletion. This keeps normal deletes successful, preserves the first-publication `published_at`, and leaves the deferred trigger as the direct-SQL and concurrency backstop. + +- [ ] **Step 6: Run the Task 1 tests to verify GREEN** + +Run: ```powershell -git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs -git commit -m "feat(data): add completed game portfolio schema" +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests" +``` + +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, successful explicit unpublish plus session delete with preserved `published_at`, bounded concurrent publish/delete without deadlock or invalid public commit, and successful parent-card and owning-group cascades. + +- [ ] **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 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/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +git commit -m "fix(data): harden portfolio publication concurrency" ``` --- -- 2.52.0 From 536061f63c53f4ef4f1f70d8af5da0ff37d1c799 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 10:04:44 +0300 Subject: [PATCH 10/31] docs: sync portfolio task 1 review indexes --- .../plans/2026-05-30-completed-game-portfolio.md | 14 ++++++++++++++ .../2026-05-30-completed-game-portfolio-design.md | 2 ++ 2 files changed, 16 insertions(+) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 80cc2ca..fec8ca8 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -29,6 +29,9 @@ - `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/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` @@ -39,6 +42,10 @@ **Modify** +- `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs` +- `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.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` @@ -334,6 +341,13 @@ CREATE TABLE portfolio_game_reviews ( 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; diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index f3a4f95..c627e27 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -126,6 +126,8 @@ CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')) UNIQUE (portfolio_game_id, author_player_id) ``` +- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`. +- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`. - Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`. - Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`. -- 2.52.0 From 76b3ff7ddfafeab28a49cdafad3cfb1e990f11e1 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 14:12:29 +0300 Subject: [PATCH 11/31] fix(data): serialize portfolio publication validation --- compose.yaml | 4 + .../2026-05-30-completed-game-portfolio.md | 40 +++- ...6-05-30-completed-game-portfolio-design.md | 14 +- ..._completed_game_portfolios_and_reviews.sql | 2 + .../Sessions/DiscordDeleteSessionHandler.cs | 4 +- .../Web/PortfolioMigrationPostgresTests.cs | 194 +++++++++++++++--- .../Web/PortfolioMigrationTests.cs | 1 + .../Web/PortfolioSchemaGateSourceTests.cs | 59 ++++++ .../PortfolioSessionDeletionSourceTests.cs | 1 + 9 files changed, 287 insertions(+), 32 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs diff --git a/compose.yaml b/compose.yaml index 6820840..0ed1924 100644 --- a/compose.yaml +++ b/compose.yaml @@ -72,6 +72,8 @@ services: depends_on: db: condition: service_healthy + bot: + condition: service_healthy environment: - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" @@ -89,6 +91,8 @@ services: depends_on: db: condition: service_healthy + bot: + condition: service_healthy environment: - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index fec8ca8..0551a97 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -30,6 +30,7 @@ - `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` @@ -69,6 +70,7 @@ **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` @@ -76,6 +78,7 @@ - Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.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** @@ -106,6 +109,7 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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("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 DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); @@ -157,12 +161,15 @@ public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInt "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(unpublish, source, StringComparison.Ordinal); + Assert.Contains("AND p.platform = 'Discord'", source, 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`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. + - [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** Add the package reference: @@ -196,18 +203,33 @@ public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirs [Fact] public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() +[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")] +[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 theory must expect PostgreSQL `23514` at commit for each required-link table. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The concurrency scenario must bound both commits with timeouts, prove there is no deadlock, and prove that an invalid public card cannot commit. The parent-card and owning-group cascade scenarios must commit successfully. +The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. 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" +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" ``` Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist and the session-deletion handlers do not explicitly unpublish linked portfolio cards before deleting sessions. @@ -273,6 +295,8 @@ AS $$ DECLARE target_portfolio_game_id UUID; BEGIN + PERFORM pg_advisory_xact_lock(20260530, 108); + IF TG_TABLE_NAME = 'portfolio_games' THEN target_portfolio_game_id := NEW.id; ELSE @@ -357,7 +381,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they reject a surviving published card when either required link set is empty. Child delete triggers do not lock or update the parent card, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. The intentionally global lock is appropriate for low-volume portfolio publication writes: it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. Child delete triggers do not lock or update the parent card. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. - [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** @@ -391,20 +415,24 @@ WHERE pgs.portfolio_game_id = pg.id Both handlers deliberately unpublish before session deletion. This 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. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. + - [ ] **Step 6: Run the Task 1 tests to verify GREEN** Run: ```powershell -dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests" +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, successful explicit unpublish plus session delete with preserved `published_at`, bounded concurrent publish/delete without deadlock or invalid public commit, and successful parent-card and owning-group cascades. +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. - [ ] **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 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/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +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 compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs git commit -m "fix(data): harden portfolio publication concurrency" ``` diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index c627e27..9556906 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,7 +79,7 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. The deferred guard is a database backstop and deliberately does not lock or update a parent card from a child delete trigger, avoiding reverse lock order. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew when concurrent transactions remove different links, and avoids multi-card deadlocks. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -311,7 +311,15 @@ Add: ```yaml services: + discord: + depends_on: + bot: + condition: service_healthy + web: + depends_on: + bot: + condition: service_healthy environment: - "PortfolioCovers__StoragePath=/app/portfolio-covers" volumes: @@ -326,6 +334,8 @@ Development configuration uses a local directory under the application content r The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. +The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. + --- ## Documentation @@ -344,7 +354,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, explicit unpublish before session deletion, concurrent publish/delete ordering without deadlock, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 50843d1..90b3abb 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -59,6 +59,8 @@ AS $$ DECLARE target_portfolio_game_id UUID; BEGIN + PERFORM pg_advisory_xact_lock(20260530, 108); + IF TG_TABLE_NAME = 'portfolio_games' THEN target_portfolio_game_id := NEW.id; ELSE diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index d1f6462..c0cf6e1 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -32,7 +32,9 @@ public sealed class DiscordDeleteSessionHandler( FROM group_managers gm JOIN players p ON p.id = gm.player_id JOIN game_groups g ON g.id = gm.group_id - WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId", + WHERE g.platform = 'Discord' + AND p.platform = 'Discord' + AND g.external_group_id = @GuildId", new { GuildId = guildId }); if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions)) diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 9306172..3076abe 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -74,7 +74,7 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi connection, "DELETE FROM sessions WHERE id = @sessionId", transaction, - new NpgsqlParameter("sessionId", seed.SessionId)); + new NpgsqlParameter("sessionId", seed.SessionIds[0])); await transaction.CommitAsync().WaitAsync(CommandTimeout); @@ -133,6 +133,105 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } + [Theory] + [InlineData("portfolio_game_sessions", "session_id")] + [InlineData("portfolio_game_masters", "player_id")] + public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidPublicCard( + string linkTable, + string linkColumn) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync( + seedConnection, + isPublic: true, + sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, + masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(); + var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; + + await ExecuteNonQueryAsync( + firstConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + firstTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[0])); + await ExecuteNonQueryAsync( + secondConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + secondTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[1])); + + var commitStates = await Task.WhenAll( + CommitAndCaptureSqlStateAsync(firstTransaction), + CommitAndCaptureSqlStateAsync(secondTransaction)); + + Assert.Single(commitStates, state => state is null); + Assert.Single(commitStates, state => state == PostgresErrorCodes.CheckViolation); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(1, await ExecuteScalarAsync( + verificationConnection, + $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData("portfolio_game_sessions")] + [InlineData("portfolio_game_masters")] + public async Task MovingLastRequiredLinkAway_ShouldFailCommitForPublishedCard(string linkTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var source = await SeedCardAsync(connection, isPublic: true); + var destination = await SeedCardAsync(connection, isPublic: false); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"UPDATE {linkTable} SET portfolio_game_id = @destinationId WHERE portfolio_game_id = @sourceId", + transaction, + new NpgsqlParameter("destinationId", destination.PortfolioGameId), + new NpgsqlParameter("sourceId", source.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Theory] + [InlineData("sessions")] + [InlineData("players")] + public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(string parentTable) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + $"DELETE FROM {parentTable} WHERE id = @parentId", + transaction, + new NpgsqlParameter( + "parentId", + parentTable == "sessions" ? seed.SessionIds[0] : seed.MasterIds[0])); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + [Fact] public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() { @@ -161,29 +260,42 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi ])); } - private static async Task SeedCardAsync(NpgsqlConnection connection, bool isPublic) + private static async Task SeedCardAsync( + NpgsqlConnection connection, + bool isPublic, + int sessionCount = 1, + int masterCount = 1) { - var playerId = Guid.NewGuid(); var groupId = Guid.NewGuid(); - var sessionId = Guid.NewGuid(); var portfolioGameId = Guid.NewGuid(); - var legacyId = Interlocked.Increment(ref nextLegacyId); + var sessionIds = Enumerable.Range(0, sessionCount).Select(_ => Guid.NewGuid()).ToArray(); + var masterIds = Enumerable.Range(0, masterCount).Select(_ => Guid.NewGuid()).ToArray(); var publishedAtValue = DateTime.UtcNow.AddDays(-1); var publishedAt = new DateTime(publishedAtValue.Ticks / 10 * 10, DateTimeKind.Utc); await using var transaction = await connection.BeginTransactionAsync(); + foreach (var masterId in masterIds) + { + var legacyId = Interlocked.Increment(ref nextLegacyId); + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) + VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); + """, + transaction, + new NpgsqlParameter("playerId", masterId), + new NpgsqlParameter("legacyId", legacyId), + new NpgsqlParameter("legacyIdText", legacyId.ToString())); + } + + var groupLegacyId = Interlocked.Increment(ref nextLegacyId); await ExecuteNonQueryAsync( connection, """ - INSERT INTO players (id, telegram_id, display_name, platform, external_user_id) - VALUES (@playerId, @legacyId, 'Portfolio GM', 'Telegram', @legacyIdText); - INSERT INTO game_groups (id, telegram_chat_id, name, gm_telegram_id, platform, external_group_id) VALUES (@groupId, @legacyId, 'Portfolio Club', @legacyId, 'Telegram', @legacyIdText); - INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) - VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); - INSERT INTO portfolio_games ( id, group_id, @@ -202,26 +314,61 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi 'covers/example.webp', @isPublic, CASE WHEN @isPublic THEN @publishedAt ELSE NULL END); - - INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) - VALUES (@portfolioGameId, @sessionId); - - INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) - VALUES (@portfolioGameId, @playerId); """, transaction, - new NpgsqlParameter("playerId", playerId), - new NpgsqlParameter("legacyId", legacyId), - new NpgsqlParameter("legacyIdText", legacyId.ToString()), + new NpgsqlParameter("legacyId", groupLegacyId), + new NpgsqlParameter("legacyIdText", groupLegacyId.ToString()), new NpgsqlParameter("groupId", groupId), - new NpgsqlParameter("sessionId", sessionId), new NpgsqlParameter("portfolioGameId", portfolioGameId), new NpgsqlParameter("publicSlug", $"portfolio-{portfolioGameId:N}"), new NpgsqlParameter("isPublic", isPublic), new NpgsqlParameter("publishedAt", publishedAt)); + foreach (var sessionId in sessionIds) + { + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + """, + transaction, + new NpgsqlParameter("sessionId", sessionId), + new NpgsqlParameter("groupId", groupId), + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + + foreach (var masterId in masterIds) + { + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + VALUES (@portfolioGameId, @playerId); + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId), + new NpgsqlParameter("playerId", masterId)); + } + await transaction.CommitAsync().WaitAsync(CommandTimeout); - return new PortfolioSeed(portfolioGameId, groupId, sessionId, publishedAt); + return new PortfolioSeed(portfolioGameId, groupId, sessionIds, masterIds, publishedAt); + } + + private static async Task CommitAndCaptureSqlStateAsync(NpgsqlTransaction transaction) + { + try + { + await transaction.CommitAsync().WaitAsync(CommandTimeout); + return null; + } + catch (PostgresException exception) + { + return exception.SqlState; + } } private static async Task ExecuteNonQueryAsync( @@ -249,6 +396,7 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi private sealed record PortfolioSeed( Guid PortfolioGameId, Guid GroupId, - Guid SessionId, + Guid[] SessionIds, + Guid[] MasterIds, DateTime PublishedAt); } diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index a5d1bce..147150c 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -31,6 +31,7 @@ public sealed class PortfolioMigrationTests Assert.Contains("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;", 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("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';", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs new file mode 100644 index 0000000..7ee2fac --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs @@ -0,0 +1,59 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioSchemaGateSourceTests +{ + [Fact] + public async Task Compose_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + AssertServiceDependsOnHealthyBot(compose, "discord"); + AssertServiceDependsOnHealthyBot(compose, "web"); + } + + private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) + { + var serviceBlock = GetServiceBlock(compose, serviceName); + + Assert.Contains( + """ + bot: + condition: service_healthy + """, + serviceBlock, + StringComparison.Ordinal); + } + + private static string GetServiceBlock(string compose, string serviceName) + { + var lines = compose.Split('\n'); + var start = Array.FindIndex(lines, line => line.TrimEnd('\r') == $" {serviceName}:"); + Assert.True(start >= 0, $"compose.yaml should contain service '{serviceName}'."); + + var end = Array.FindIndex( + lines, + start + 1, + line => line.StartsWith(" ", StringComparison.Ordinal) + && !line.StartsWith(" ", StringComparison.Ordinal) + && line.TrimEnd('\r').EndsWith(':')); + + return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs index f32c441..3dfa11e 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -28,6 +28,7 @@ public sealed class PortfolioSessionDeletionSourceTests "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(unpublish, source, StringComparison.Ordinal); + Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal); Assert.True( source.IndexOf(unpublish, StringComparison.Ordinal) < source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal), -- 2.52.0 From 6e7a0cb493d2657454edb2236e5158e8de7da730 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 14:28:51 +0300 Subject: [PATCH 12/31] fix(data): enforce portfolio validation isolation --- .../2026-05-30-completed-game-portfolio.md | 25 ++++++- ...6-05-30-completed-game-portfolio-design.md | 4 +- ..._completed_game_portfolios_and_reviews.sql | 12 +++ .../Web/PortfolioMigrationPostgresTests.cs | 73 +++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 2 + 5 files changed, 111 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 0551a97..f65aba6 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -110,6 +110,8 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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("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 DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); @@ -208,6 +210,11 @@ public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvali [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("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -222,7 +229,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() ``` -The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The parent-card and owning-group cascade scenarios must commit successfully. +The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The equivalent `REPEATABLE READ` scenario must reject both published-card transactions with `0A000`, 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** @@ -303,6 +310,18 @@ BEGIN target_portfolio_game_id := OLD.portfolio_game_id; END IF; + IF current_setting('transaction_isolation') <> 'read committed' + AND EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + ) THEN + RAISE EXCEPTION + 'portfolio publication validation requires read committed isolation' + USING ERRCODE = '0A000'; + END IF; + IF EXISTS ( SELECT 1 FROM portfolio_games pg @@ -381,7 +400,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. The intentionally global lock is appropriate for low-volume portfolio publication writes: it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. Child delete triggers do not lock or update the parent card. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. Card and club cascade deletion remain harmless because no published parent survives validation. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. 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 publication-related writes at those isolation levels with `0A000`. Child delete triggers do not lock or update the parent card. Draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. - [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** @@ -427,7 +446,7 @@ 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, successful explicit unpublish plus session delete with preserved `published_at`, bounded concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` published-card writes, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. - [ ] **Step 7: Commit** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 9556906..690887f 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,7 +79,7 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew when concurrent transactions remove different links, and avoids multi-card deadlocks. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects any transaction at those levels that would leave a published card; callers must use `READ COMMITTED` for publication-related writes. Draft changes, explicit unpublishing, and parent-card or club cascade deletion remain allowed. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -354,7 +354,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 90b3abb..a614343 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -67,6 +67,18 @@ BEGIN target_portfolio_game_id := OLD.portfolio_game_id; END IF; + IF current_setting('transaction_isolation') <> 'read committed' + AND EXISTS ( + SELECT 1 + FROM portfolio_games pg + WHERE pg.id = target_portfolio_game_id + AND pg.is_public = true + ) THEN + RAISE EXCEPTION + 'portfolio publication validation requires read committed isolation' + USING ERRCODE = '0A000'; + END IF; + IF EXISTS ( SELECT 1 FROM portfolio_games pg diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 3076abe..a366f33 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -1,4 +1,5 @@ using Npgsql; +using System.Data; namespace GmRelay.Bot.Tests.Web; @@ -184,6 +185,78 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } + [Theory] + [InlineData("portfolio_game_sessions", "session_id")] + [InlineData("portfolio_game_masters", "player_id")] + public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard( + string linkTable, + string linkColumn) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync( + seedConnection, + isPublic: true, + sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1, + masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; + + await ExecuteNonQueryAsync( + firstConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + firstTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[0])); + await ExecuteNonQueryAsync( + secondConnection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", + secondTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("linkId", linkIds[1])); + + var commitStates = await Task.WhenAll( + CommitAndCaptureSqlStateAsync(firstTransaction), + CommitAndCaptureSqlStateAsync(secondTransaction)); + + Assert.All(commitStates, state => Assert.Equal(PostgresErrorCodes.FeatureNotSupported, state)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(2, await ExecuteScalarAsync( + verificationConnection, + $"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Theory] + [InlineData(IsolationLevel.RepeatableRead)] + [InlineData(IsolationLevel.Serializable)] + public async Task NonReadCommittedPublishedCardLinkDelete_ShouldBeRejected(IsolationLevel isolationLevel) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(isolationLevel); + + await ExecuteNonQueryAsync( + connection, + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); + } + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 147150c..fee39ca 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -32,6 +32,8 @@ public sealed class PortfolioMigrationTests 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("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';", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); -- 2.52.0 From f493836b7726ad58cd4738e1321fad51cc1caa45 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 14:39:04 +0300 Subject: [PATCH 13/31] fix(data): reject stale portfolio trigger snapshots --- .../2026-05-30-completed-game-portfolio.md | 17 +++---- ...6-05-30-completed-game-portfolio-design.md | 4 +- ..._completed_game_portfolios_and_reviews.sql | 8 +--- .../Web/PortfolioMigrationPostgresTests.cs | 45 +++++++++++++++++++ 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index f65aba6..b30525f 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -215,6 +215,9 @@ public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidP [InlineData("portfolio_game_masters", "player_id")] public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn) +[Fact] +public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -229,7 +232,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() ``` -The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The equivalent `REPEATABLE READ` scenario must reject both published-card transactions with `0A000`, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. +The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including draft-link deletion racing with 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** @@ -310,13 +313,7 @@ BEGIN target_portfolio_game_id := OLD.portfolio_game_id; END IF; - IF current_setting('transaction_isolation') <> 'read committed' - AND EXISTS ( - SELECT 1 - FROM portfolio_games pg - WHERE pg.id = target_portfolio_game_id - AND pg.is_public = true - ) THEN + IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'portfolio publication validation requires read committed isolation' USING ERRCODE = '0A000'; @@ -400,7 +397,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. 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 publication-related writes at those isolation levels with `0A000`. Child delete triggers do not lock or update the parent card. Draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. 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`. Child delete triggers do not lock or update the parent card. At `READ COMMITTED`, draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. - [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** @@ -446,7 +443,7 @@ 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, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` published-card writes, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including draft-delete versus publish races, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. - [ ] **Step 7: Commit** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 690887f..50e6902 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,7 +79,7 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects any transaction at those levels that would leave a published card; callers must use `READ COMMITTED` for publication-related writes. Draft changes, explicit unpublishing, and parent-card or club cascade deletion remain allowed. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted, deferred validation sees no surviving published card and remains harmless. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -354,7 +354,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including draft-delete versus publish races, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index a614343..9671ab9 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -67,13 +67,7 @@ BEGIN target_portfolio_game_id := OLD.portfolio_game_id; END IF; - IF current_setting('transaction_isolation') <> 'read committed' - AND EXISTS ( - SELECT 1 - FROM portfolio_games pg - WHERE pg.id = target_portfolio_game_id - AND pg.is_public = true - ) THEN + IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'portfolio publication validation requires read committed isolation' USING ERRCODE = '0A000'; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index a366f33..8dbab2d 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -257,6 +257,51 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); } + [Fact] + public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + await using var deleteConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + deleteConnection, + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + deleteTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + await ExecuteNonQueryAsync( + publishConnection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var deleteSqlState = await CommitAndCaptureSqlStateAsync(deleteTransaction); + var publishSqlState = await CommitAndCaptureSqlStateAsync(publishTransaction); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, deleteSqlState); + Assert.Null(publishSqlState); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(1, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] -- 2.52.0 From da0a3063400565f32692542c4c2063b00f8680d0 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 15:04:20 +0300 Subject: [PATCH 14/31] fix(data): enforce completed portfolio sessions --- .../2026-05-30-completed-game-portfolio.md | 122 ++++++++++---- ...6-05-30-completed-game-portfolio-design.md | 15 +- src/GmRelay.AppHost/Program.cs | 8 +- ..._completed_game_portfolios_and_reviews.sql | 80 ++++++--- .../Web/PortfolioMigrationPostgresTests.cs | 155 ++++++++++++++++-- .../Web/PortfolioMigrationTests.cs | 7 +- .../Web/PortfolioSchemaGateSourceTests.cs | 24 +++ 7 files changed, 336 insertions(+), 75 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index b30525f..98377c8 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -45,6 +45,7 @@ - `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` @@ -76,6 +77,7 @@ - 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` @@ -112,10 +114,13 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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 DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); } ``` @@ -170,7 +175,7 @@ public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInt } ``` -Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. +Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. - [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** @@ -202,8 +207,10 @@ public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(st [Fact] public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() -[Fact] -public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() +[Theory] +[InlineData(true)] +[InlineData(false)] +public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst) [Theory] [InlineData("portfolio_game_sessions", "session_id")] @@ -215,8 +222,19 @@ public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidP [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 RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() +public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() + +[Fact] +public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() + +[Fact] +public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard() [Theory] [InlineData("portfolio_game_sessions")] @@ -232,7 +250,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() ``` -The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including draft-link deletion racing with publication, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. +The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded commit tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. 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** @@ -242,7 +260,7 @@ Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" ``` -Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist and the session-deletion handlers do not explicitly unpublish linked portfolio cards before deleting sessions. +Expected during the Task 1 quality-review fix: FAIL because V029 does not yet validate completed linked sessions or automatically unpublish on future reschedule, and the Aspire AppHost does not yet gate `discord` and `web` on `bot`. - [ ] **Step 4: Add migration V029** @@ -304,13 +322,18 @@ 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_id := NEW.id; + 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_id := OLD.portfolio_game_id; + target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id]; END IF; IF current_setting('transaction_isolation') <> 'read committed' THEN @@ -319,24 +342,33 @@ BEGIN USING ERRCODE = '0A000'; END IF; - IF EXISTS ( - SELECT 1 - FROM portfolio_games pg - WHERE pg.id = target_portfolio_game_id - AND pg.is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions pgs - WHERE pgs.portfolio_game_id = target_portfolio_game_id - ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters pgm - WHERE pgm.portfolio_game_id = target_portfolio_game_id - ) + 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 ) - ) THEN + 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 @@ -347,6 +379,32 @@ BEGIN 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 @@ -354,7 +412,7 @@ FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links(); CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links -AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions +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(); @@ -397,7 +455,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. 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`. Child delete triggers do not lock or update the parent card. At `READ COMMITTED`, draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the card before validator lock acquisition so a racing publication cannot create an inverted lock order. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. - [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** @@ -433,7 +491,7 @@ Both handlers deliberately unpublish before session deletion. This keeps normal 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. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. +In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. - [ ] **Step 6: Run the Task 1 tests to verify GREEN** @@ -443,12 +501,12 @@ 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, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including draft-delete versus publish races, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, 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 schema gating. - [ ] **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 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 add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs git commit -m "fix(data): harden portfolio publication concurrency" ``` @@ -938,7 +996,7 @@ Rules: - 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 past sessions, 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. +- 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. diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 50e6902..7a63439 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -77,9 +77,11 @@ CHECK (NOT is_public OR ( - Index on `(group_id, completed_at DESC)`. - Partial public index on `(completed_at DESC)` where `is_public = true`. -Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. +Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. + +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update, avoiding an inverted lock order. Normal session-deletion handlers still explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -90,7 +92,7 @@ Deferred database constraint triggers validate the same invariant at transaction Primary key: `(portfolio_game_id, session_id)`. -The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. A session belongs to at most one portfolio adventure. +The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure. ### `portfolio_game_masters` @@ -334,7 +336,7 @@ Development configuration uses a local directory under the application content r The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. -The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. +The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this ordering: its `discord` and `web` project resources wait for both PostgreSQL and the `bot` resource. --- @@ -353,8 +355,8 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including draft-delete versus publish races, and parent/card cascade deletion. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, and AppHost schema gate. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. @@ -410,6 +412,7 @@ Synchronize: - [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description. - [ ] A portfolio adventure can group one or more completed sessions from the same club. +- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp. - [ ] Selected public GM profiles show portfolio cards independently of club-page visibility. - [ ] A public club page shows portfolio cards when enabled. - [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews. diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs index 6578e71..633b555 100644 --- a/src/GmRelay.AppHost/Program.cs +++ b/src/GmRelay.AppHost/Program.cs @@ -4,16 +4,18 @@ var postgres = builder.AddPostgres("postgres") .WithPgAdmin() .AddDatabase("gmrelay-db"); -builder.AddProject("bot") +var bot = builder.AddProject("bot") .WithReference(postgres) .WaitFor(postgres); builder.AddProject("discord") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WaitFor(bot); builder.AddProject("web") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WaitFor(bot); builder.Build().Run(); diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 9671ab9..6f22edc 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -58,13 +58,18 @@ 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_id := NEW.id; + 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_id := OLD.portfolio_game_id; + target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id]; END IF; IF current_setting('transaction_isolation') <> 'read committed' THEN @@ -73,24 +78,33 @@ BEGIN USING ERRCODE = '0A000'; END IF; - IF EXISTS ( - SELECT 1 - FROM portfolio_games pg - WHERE pg.id = target_portfolio_game_id - AND pg.is_public = true - AND ( - NOT EXISTS ( - SELECT 1 - FROM portfolio_game_sessions pgs - WHERE pgs.portfolio_game_id = target_portfolio_game_id - ) - OR NOT EXISTS ( - SELECT 1 - FROM portfolio_game_masters pgm - WHERE pgm.portfolio_game_id = target_portfolio_game_id - ) + 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 ) - ) THEN + 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 @@ -101,6 +115,32 @@ BEGIN 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 @@ -108,7 +148,7 @@ FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links(); CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links -AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions +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(); diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 8dbab2d..3d740a0 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -93,8 +93,10 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } - [Fact] - public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var publishConnection = await database.OpenConnectionAsync(); @@ -121,17 +123,27 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", deleteTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); - await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); - var exception = await Assert.ThrowsAsync( - () => publishTransaction.CommitAsync().WaitAsync(CommandTimeout)); - Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + await AcquirePortfolioValidationLockAsync( + publishCommitsFirst ? publishConnection : deleteConnection, + publishCommitsFirst ? publishTransaction : deleteTransaction); + + var commitStates = await Task.WhenAll( + CommitAndCaptureSqlStateAsync(publishTransaction), + CommitAndCaptureSqlStateAsync(deleteTransaction)).WaitAsync(CommandTimeout); + + Assert.Equal(publishCommitsFirst ? null : PostgresErrorCodes.CheckViolation, commitStates[0]); + Assert.Equal(publishCommitsFirst ? PostgresErrorCodes.CheckViolation : null, commitStates[1]); await using var verificationConnection = await database.OpenConnectionAsync(); - Assert.False(await ExecuteScalarAsync( + Assert.Equal(publishCommitsFirst, await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(publishCommitsFirst ? 1L : 0L, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } [Theory] @@ -257,8 +269,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); } - [Fact] - public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard( + bool publishCommitsFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); @@ -285,11 +300,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi publishTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); - var deleteSqlState = await CommitAndCaptureSqlStateAsync(deleteTransaction); - var publishSqlState = await CommitAndCaptureSqlStateAsync(publishTransaction); + await AcquirePortfolioValidationLockAsync( + publishCommitsFirst ? publishConnection : deleteConnection, + publishCommitsFirst ? publishTransaction : deleteTransaction); - Assert.Equal(PostgresErrorCodes.FeatureNotSupported, deleteSqlState); - Assert.Null(publishSqlState); + var commitStates = await Task.WhenAll( + CommitAndCaptureSqlStateAsync(deleteTransaction), + CommitAndCaptureSqlStateAsync(publishTransaction)).WaitAsync(CommandTimeout); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, commitStates[0]); + Assert.Null(commitStates[1]); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( @@ -302,6 +322,105 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } + [Fact] + public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + Assert.False(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: false, sessionCount: 2); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[1])); + await using var transaction = await connection.BeginTransactionAsync(); + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + + var exception = await Assert.ThrowsAsync( + () => transaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); + } + + [Fact] + public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(publishConnection, isPublic: false); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + publishConnection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + await ExecuteNonQueryAsync( + rescheduleConnection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + rescheduleTransaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + var commitStates = await Task.WhenAll( + CommitAndCaptureSqlStateAsync(publishTransaction), + CommitAndCaptureSqlStateAsync(rescheduleTransaction)).WaitAsync(CommandTimeout); + + Assert.True( + commitStates[0] is null or PostgresErrorCodes.CheckViolation, + $"Unexpected publish SQLSTATE: {commitStates[0] ?? ""}."); + Assert.Null(commitStates[1]); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); + } + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -489,6 +608,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi } } + private static Task AcquirePortfolioValidationLockAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) + { + return ExecuteNonQueryAsync( + connection, + "SELECT pg_advisory_xact_lock(20260530, 108)", + transaction); + } + private static async Task ExecuteNonQueryAsync( NpgsqlConnection connection, string sql, diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index fee39ca..e1e6dfc 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -34,10 +34,15 @@ public sealed class PortfolioMigrationTests 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("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';", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at AND NEW.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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;", 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 FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs index 7ee2fac..705c0ef 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs @@ -11,6 +11,25 @@ public sealed class PortfolioSchemaGateSourceTests AssertServiceDependsOnHealthyBot(compose, "web"); } + [Fact] + public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy() + { + var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); + + Assert.Contains( + "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres);", + appHost, + StringComparison.Ordinal); + Assert.Contains( + "builder.AddProject(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);", + appHost, + StringComparison.Ordinal); + Assert.Contains( + "builder.AddProject(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);", + appHost, + StringComparison.Ordinal); + } + private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) { var serviceBlock = GetServiceBlock(compose, serviceName); @@ -40,6 +59,11 @@ public sealed class PortfolioSchemaGateSourceTests return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]); } + private static string NormalizeSource(string source) + { + return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); -- 2.52.0 From 2b725708efbbe24520cbfef8ae2729194d72632d Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 20:00:59 +0300 Subject: [PATCH 15/31] test(discord): keep Moscow time parsing fixture in future --- .../Discord/DiscordNewSessionHandlerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs index 53c70fd..d72e9d5 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordNewSessionHandlerTests.cs @@ -20,7 +20,10 @@ public sealed class DiscordNewSessionHandlerTests [Fact] public void ParseTimeInput_ShouldTreatInputAsMoscowTime() { - var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00"); + var future = DateTimeOffset.UtcNow.AddDays(7); + var result = DiscordNewSessionHandler.ParseTimeInput( + future.ToString("yyyy-MM-dd '15:00'", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(result.IsSuccess); // 15:00 MSK = 12:00 UTC Assert.Equal(12, result.Value.Hour); -- 2.52.0 From a28b75dd5b5126509dcbf232d07a61535a2927bd Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 20:23:43 +0300 Subject: [PATCH 16/31] fix(data): align portfolio mutation lock order --- .../2026-05-30-completed-game-portfolio.md | 38 +++-- ...6-05-30-completed-game-portfolio-design.md | 8 +- src/GmRelay.AppHost/Program.cs | 4 +- .../Sessions/DiscordDeleteSessionHandler.cs | 13 ++ .../ListSessions/DeleteSessionHandler.cs | 3 +- .../Web/PortfolioMigrationPostgresTests.cs | 156 ++++++++++++++++++ .../Web/PortfolioSchemaGateSourceTests.cs | 2 +- .../PortfolioSessionDeletionSourceTests.cs | 18 +- 8 files changed, 220 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 98377c8..bfcd1ce 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -140,42 +140,54 @@ public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() } ``` -Add `PortfolioSessionDeletionSourceTests.cs`. Normalize whitespace before comparing source text and assert that both session-deletion paths explicitly unpublish linked cards before deleting the required session link: +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: ```csharp [Fact] -public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession() +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_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession() +public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession() { var source = NormalizeSql(await ReadRepositoryFileAsync( "src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs")); + const string sessionLock = + "SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s"; const string unpublish = "UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id JOIN game_groups g ON g.id = s.group_id WHERE pgs.portfolio_game_id = pg.id AND s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId AND pg.is_public = true"; + Assert.Contains(sessionLock, source, StringComparison.Ordinal); Assert.Contains(unpublish, source, StringComparison.Ordinal); Assert.Contains("AND p.platform = 'Discord'", source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(sessionLock, StringComparison.Ordinal) < + source.IndexOf(unpublish, StringComparison.Ordinal)); Assert.True( source.IndexOf(unpublish, StringComparison.Ordinal) < source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal)); } ``` -Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable 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. +Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. - [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** @@ -250,7 +262,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() ``` -The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded commit tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. 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. +The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common `sessions` then `portfolio_games` lock order, cover both first-session-lock orders through real blocking transactions, and finish with the card private and session deleted. The publish/reschedule race must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. - [ ] **Step 3: Run the Task 1 tests to verify RED** @@ -260,7 +272,7 @@ Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" ``` -Expected during the Task 1 quality-review fix: FAIL because V029 does not yet validate completed linked sessions or automatically unpublish on future reschedule, and the Aspire AppHost does not yet gate `discord` and `web` on `bot`. +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** @@ -455,11 +467,11 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the card before validator lock acquisition so a racing publication cannot create an inverted lock order. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. +The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the session before the card. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same `sessions` then `portfolio_games` lock order: explicitly lock the target session row, unpublish linked cards, then delete the session. -- [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** +- [ ] **Step 5: Lock sessions before explicitly unpublishing linked cards in both session-deletion handlers** -In `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`, run this statement inside the existing transaction after authorization and before `DELETE FROM sessions`: +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`: ```sql UPDATE portfolio_games pg @@ -471,7 +483,7 @@ WHERE pgs.portfolio_game_id = pg.id AND pg.is_public = true ``` -In `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`, start a transaction before deleting. Run this guild-scoped unpublish statement before the existing guild-scoped `DELETE FROM sessions`, then commit: +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: ```sql UPDATE portfolio_games pg @@ -487,11 +499,11 @@ WHERE pgs.portfolio_game_id = pg.id AND pg.is_public = true ``` -Both handlers deliberately unpublish before session deletion. This keeps normal deletes successful, preserves the first-publication `published_at`, and leaves the deferred trigger as the direct-SQL and concurrency backstop. +Both handlers deliberately use `sessions` then `portfolio_games` locking before session deletion. This matches future rescheduling, keeps normal deletes successful, preserves the first-publication `published_at`, and leaves the deferred trigger as the direct-SQL and concurrency backstop. Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization. -In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource 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. +In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health")`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. - [ ] **Step 6: Run the Task 1 tests to verify GREEN** @@ -501,7 +513,7 @@ Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" ``` -Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, 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 schema gating. +Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating. - [ ] **Step 7: Commit** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 7a63439..5804610 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update, avoiding an inverted lock order. Normal session-deletion handlers still explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update. Session mutation paths use one row-lock order: `sessions` first, then linked `portfolio_games`. Normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -336,7 +336,7 @@ Development configuration uses a local directory under the application content r The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. -The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this ordering: its `discord` and `web` project resources wait for both PostgreSQL and the `bot` resource. +The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate by explicitly exposing the bot project resource's port `8081` endpoint, attaching `.WithHttpHealthCheck("/health", endpointName: "health")`, and making its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource. --- @@ -355,8 +355,8 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, and AppHost schema gate. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs index 633b555..fda75d0 100644 --- a/src/GmRelay.AppHost/Program.cs +++ b/src/GmRelay.AppHost/Program.cs @@ -6,7 +6,9 @@ var postgres = builder.AddPostgres("postgres") var bot = builder.AddProject("bot") .WithReference(postgres) - .WaitFor(postgres); + .WaitFor(postgres) + .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health") + .WithHttpHealthCheck("/health", endpointName: "health"); builder.AddProject("discord") .WithReference(postgres) diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index c0cf6e1..97c5f5c 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -45,6 +45,19 @@ public sealed class DiscordDeleteSessionHandler( } await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + _ = await connection.QuerySingleOrDefaultAsync( + """ + 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 + """, + new { SessionId = sessionId, GuildId = guildId }, + transaction); + await connection.ExecuteAsync( """ UPDATE portfolio_games pg diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs index dc88d4d..34b9b3f 100644 --- a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -31,7 +31,7 @@ public sealed class DeleteSessionHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // 1. Fetch session and verify group manager. + // 1. Lock the session before any linked portfolio card and verify group manager. var session = await connection.QuerySingleOrDefaultAsync( """ SELECT s.title AS Title, @@ -49,6 +49,7 @@ public sealed class DeleteSessionHandler( ) AS CanManage FROM sessions s WHERE s.id = @SessionId + FOR UPDATE OF s """, new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction); diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 3d740a0..4a80164 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -421,6 +421,70 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeSessionBeforeCardWithoutDeadlock( + bool deleteLocksSessionFirst) + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: true); + await using var deleteConnection = await database.OpenConnectionAsync(); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + + if (deleteLocksSessionFirst) + { + await LockSessionAsync(deleteConnection, deleteTransaction, seed.SessionIds[0]); + var rescheduleTask = RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, deletePid); + await UnpublishAndDeleteSessionAsync( + deleteConnection, + deleteTransaction, + seed.PortfolioGameId, + seed.SessionIds[0]); + await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(0, await rescheduleTask.WaitAsync(CommandTimeout)); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + } + else + { + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0])); + var deleteTask = LockUnpublishDeleteAndCommitSessionAsync( + deleteConnection, + deleteTransaction, + seed.PortfolioGameId, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, deletePid, reschedulePid); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + await deleteTask.WaitAsync(CommandTimeout); + } + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + "SELECT COUNT(*) FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); + } + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -618,6 +682,98 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi transaction); } + private static Task GetBackendPidAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) + { + return ExecuteScalarAsync(connection, "SELECT pg_backend_pid()", transaction); + } + + private static Task LockSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid sessionId) + { + return ExecuteNonQueryAsync( + connection, + "SELECT 1 FROM sessions s WHERE s.id = @sessionId FOR UPDATE OF s", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static Task RescheduleSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid sessionId) + { + return ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static async Task LockUnpublishDeleteAndCommitSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId, + Guid sessionId) + { + await LockSessionAsync(connection, transaction, sessionId); + await UnpublishAndDeleteSessionAsync(connection, transaction, portfolioGameId, sessionId); + await transaction.CommitAsync().WaitAsync(CommandTimeout); + } + + private static async Task UnpublishAndDeleteSessionAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId, + Guid sessionId) + { + await ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = false, + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + await ExecuteNonQueryAsync( + connection, + "DELETE FROM sessions WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", sessionId)); + } + + private static async Task WaitUntilBlockedByAsync( + NpgsqlConnection observerConnection, + int blockedPid, + int blockingPid) + { + using var timeout = new CancellationTokenSource(CommandTimeout); + while (!timeout.IsCancellationRequested) + { + if (await ExecuteScalarAsync( + observerConnection, + "SELECT @blockingPid = ANY (pg_blocking_pids(@blockedPid))", + parameters: + [ + new NpgsqlParameter("blockedPid", blockedPid), + new NpgsqlParameter("blockingPid", blockingPid) + ])) + { + return; + } + + await Task.Yield(); + } + + throw new TimeoutException( + $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); + } + private static async Task ExecuteNonQueryAsync( NpgsqlConnection connection, string sql, diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs index 705c0ef..44fa0c6 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs @@ -17,7 +17,7 @@ public sealed class PortfolioSchemaGateSourceTests var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); Assert.Contains( - "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres);", + "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\") .WithHttpHealthCheck(\"/health\", endpointName: \"health\");", appHost, StringComparison.Ordinal); Assert.Contains( diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs index 3dfa11e..f621caf 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -3,15 +3,22 @@ namespace GmRelay.Bot.Tests.Web; public sealed class PortfolioSessionDeletionSourceTests { [Fact] - public async Task SharedDeleteSessionHandler_ShouldUnpublishLinkedPortfolioCardBeforeDeletingSession() + 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), + "The shared delete path must lock the session before locking a linked portfolio card."); Assert.True( source.IndexOf(unpublish, StringComparison.Ordinal) < source.IndexOf("DELETE FROM sessions WHERE id = @Id", StringComparison.Ordinal), @@ -19,16 +26,23 @@ public sealed class PortfolioSessionDeletionSourceTests } [Fact] - public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInteractionGuildBeforeDeletingSession() + 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), + "The Discord delete path must lock the guild-scoped session before locking a linked portfolio card."); Assert.True( source.IndexOf(unpublish, StringComparison.Ordinal) < source.IndexOf("DELETE FROM sessions s", StringComparison.Ordinal), -- 2.52.0 From d762ecc377eda765a0eafdb4482431b32b0546dc Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 1 Jun 2026 20:58:53 +0300 Subject: [PATCH 17/31] fix(data): serialize portfolio future reschedules --- .../2026-05-30-completed-game-portfolio.md | 32 ++- ...6-05-30-completed-game-portfolio-design.md | 6 +- src/GmRelay.AppHost/Program.cs | 4 +- ..._completed_game_portfolios_and_reviews.sql | 35 ++- .../Web/PortfolioMigrationPostgresTests.cs | 217 ++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 17 +- .../Web/PortfolioSchemaGateSourceTests.cs | 10 +- 7 files changed, 299 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index bfcd1ce..32f4b0e 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -68,6 +68,17 @@ ### 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` @@ -120,8 +131,9 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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); - Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); } ``` @@ -187,7 +199,7 @@ public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpubl } ``` -Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable, expose its named port `8081` HTTP endpoint, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. +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** @@ -242,6 +254,12 @@ public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWit [Fact] public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt() +[Fact] +public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() + +[Fact] +public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock() + [Fact] public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() @@ -262,7 +280,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() ``` -The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. A session-delete versus future-reschedule race must use the common `sessions` then `portfolio_games` lock order, cover both first-session-lock orders through real blocking transactions, and finish with the card private and session deleted. The publish/reschedule race must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. +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** @@ -467,7 +485,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending WHERE moderation_status = 'Pending'; ``` -The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the session before the card. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers use the same `sessions` then `portfolio_games` lock order: explicitly lock the target session row, unpublish linked cards, then delete the session. +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** @@ -503,7 +521,7 @@ Both handlers deliberately use `sessions` then `portfolio_games` locking before Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization. -In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource, add `.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health")`, attach `.WithHttpHealthCheck("/health", endpointName: "health")`, and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. +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** @@ -513,13 +531,13 @@ Run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" ``` -Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, session-delete/reschedule serialization in both first-lock orders, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire HTTP health gating. +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** ```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): harden portfolio publication concurrency" +git commit -m "fix(data): serialize portfolio future reschedules" ``` --- diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 5804610..aa7f5be 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update. Session mutation paths use one row-lock order: `sessions` first, then linked `portfolio_games`. Normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all currently public cards linked to any final-future session in `portfolio_games.id` order, then unpublishes the matching cards in one guarded update. The low-volume global pass gives batch reschedules one card-lock order without taking the publication validator advisory lock before card locks. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -336,7 +336,7 @@ Development configuration uses a local directory under the application content r The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. -The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate by explicitly exposing the bot project resource's port `8081` endpoint, attaching `.WithHttpHealthCheck("/health", endpointName: "health")`, and making its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource. +The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource. --- @@ -356,7 +356,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.AppHost/Program.cs b/src/GmRelay.AppHost/Program.cs index fda75d0..562d304 100644 --- a/src/GmRelay.AppHost/Program.cs +++ b/src/GmRelay.AppHost/Program.cs @@ -2,12 +2,12 @@ var builder = DistributedApplication.CreateBuilder(args); var postgres = builder.AddPostgres("postgres") .WithPgAdmin() - .AddDatabase("gmrelay-db"); + .AddDatabase("gmrelaydb"); var bot = builder.AddProject("bot") .WithReference(postgres) .WaitFor(postgres) - .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health") + .WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false) .WithHttpHealthCheck("/health", endpointName: "health"); builder.AddProject("discord") diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 6f22edc..dd3fb39 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -119,16 +119,39 @@ CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + final_scheduled_at TIMESTAMPTZ; BEGIN - IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at - AND NEW.scheduled_at >= now() THEN + SELECT s.scheduled_at + INTO final_scheduled_at + FROM sessions s + WHERE s.id = NEW.id; + + IF final_scheduled_at >= now() THEN + PERFORM pg.id + FROM portfolio_games pg + 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() + ) + ORDER BY pg.id + FOR UPDATE OF pg; + 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; + 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; diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 4a80164..1bcef04 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -344,6 +344,123 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); } + [Fact] + public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var connection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(connection, isPublic: true); + await using var transaction = await connection.BeginTransactionAsync(); + + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + await ExecuteNonQueryAsync( + connection, + "UPDATE sessions SET scheduled_at = now() - interval '2 days' WHERE id = @sessionId", + transaction, + new NpgsqlParameter("sessionId", seed.SessionIds[0])); + + await transaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.True(await ExecuteScalarAsync( + connection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync( + connection, + "SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + } + + [Fact] + public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var firstSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); + var secondSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); + + await ExecuteNonQueryAsync( + seedConnection, + """ + CREATE FUNCTION wait_for_portfolio_card_unpublish_gate() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(20260601, 108); + RETURN NULL; + END; + $$; + + CREATE TRIGGER trg_wait_for_portfolio_card_unpublish_gate + AFTER UPDATE OF is_public ON portfolio_games + FOR EACH ROW + WHEN (OLD.is_public = true AND NEW.is_public = false) + EXECUTE FUNCTION wait_for_portfolio_card_unpublish_gate(); + """); + + await using var firstConnection = await database.OpenConnectionAsync(); + await using var secondConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var firstTransaction = await firstConnection.BeginTransactionAsync(); + await using var secondTransaction = await secondConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquireBatchRescheduleGateAsync(gateConnection, gateTransaction); + + await RescheduleSessionsAsync( + firstConnection, + firstTransaction, + firstSeed.SessionIds[0], + secondSeed.SessionIds[0]); + await RescheduleSessionsAsync( + secondConnection, + secondTransaction, + secondSeed.SessionIds[1], + firstSeed.SessionIds[1]); + + var firstCommitTask = CommitAndCaptureSqlStateAsync(firstTransaction); + var secondCommitTask = CommitAndCaptureSqlStateAsync(secondTransaction); + var gateBlockedPid = await WaitUntilEitherBlockedByAsync( + observerConnection, + firstPid, + secondPid, + gatePid); + await WaitUntilBlockedByAnyAsync( + observerConnection, + gateBlockedPid == firstPid ? secondPid : firstPid, + gatePid, + gateBlockedPid); + + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + + var commitStates = await Task.WhenAll(firstCommitTask, secondCommitTask).WaitAsync(CommandTimeout); + + Assert.All(commitStates, Assert.Null); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + """ + SELECT COUNT(*) + FROM portfolio_games + WHERE id IN (@firstPortfolioGameId, @secondPortfolioGameId) + AND is_public = true + """, + parameters: + [ + new NpgsqlParameter("firstPortfolioGameId", firstSeed.PortfolioGameId), + new NpgsqlParameter("secondPortfolioGameId", secondSeed.PortfolioGameId) + ])); + } + [Fact] public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() { @@ -682,6 +799,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi transaction); } + private static Task AcquireBatchRescheduleGateAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction) + { + return ExecuteNonQueryAsync( + connection, + "SELECT pg_advisory_xact_lock(20260601, 108)", + transaction); + } + private static Task GetBackendPidAsync( NpgsqlConnection connection, NpgsqlTransaction transaction) @@ -713,6 +840,28 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi new NpgsqlParameter("sessionId", sessionId)); } + private static Task RescheduleSessionsAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid firstSessionId, + Guid secondSessionId) + { + return ExecuteNonQueryAsync( + connection, + """ + UPDATE sessions + SET scheduled_at = now() + interval '1 day' + WHERE id = @firstSessionId; + + UPDATE sessions + SET scheduled_at = now() + interval '1 day' + WHERE id = @secondSessionId; + """, + transaction, + new NpgsqlParameter("firstSessionId", firstSessionId), + new NpgsqlParameter("secondSessionId", secondSessionId)); + } + private static async Task LockUnpublishDeleteAndCommitSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, @@ -774,6 +923,74 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); } + private static async Task WaitUntilEitherBlockedByAsync( + NpgsqlConnection observerConnection, + int firstBlockedPid, + int secondBlockedPid, + int blockingPid) + { + using var timeout = new CancellationTokenSource(CommandTimeout); + while (!timeout.IsCancellationRequested) + { + var blockedPid = await ExecuteScalarAsync( + observerConnection, + """ + SELECT CASE + WHEN @blockingPid = ANY (pg_blocking_pids(@firstBlockedPid)) THEN @firstBlockedPid + WHEN @blockingPid = ANY (pg_blocking_pids(@secondBlockedPid)) THEN @secondBlockedPid + ELSE 0 + END + """, + parameters: + [ + new NpgsqlParameter("firstBlockedPid", firstBlockedPid), + new NpgsqlParameter("secondBlockedPid", secondBlockedPid), + new NpgsqlParameter("blockingPid", blockingPid) + ]); + if (blockedPid != 0) + { + return blockedPid; + } + + await Task.Yield(); + } + + throw new TimeoutException( + $"Neither PostgreSQL backend {firstBlockedPid} nor {secondBlockedPid} was blocked by backend {blockingPid} within {CommandTimeout}."); + } + + private static async Task WaitUntilBlockedByAnyAsync( + NpgsqlConnection observerConnection, + int blockedPid, + int firstBlockingPid, + int secondBlockingPid) + { + using var timeout = new CancellationTokenSource(CommandTimeout); + while (!timeout.IsCancellationRequested) + { + if (await ExecuteScalarAsync( + observerConnection, + """ + SELECT @firstBlockingPid = ANY (pg_blocking_pids(@blockedPid)) + OR @secondBlockingPid = ANY (pg_blocking_pids(@blockedPid)) + """, + parameters: + [ + new NpgsqlParameter("blockedPid", blockedPid), + new NpgsqlParameter("firstBlockingPid", firstBlockingPid), + new NpgsqlParameter("secondBlockingPid", secondBlockingPid) + ])) + { + return; + } + + await Task.Yield(); + } + + throw new TimeoutException( + $"PostgreSQL backend {blockedPid} was not blocked by backend {firstBlockingPid} or {secondBlockingPid} within {CommandTimeout}."); + } + private static async Task ExecuteNonQueryAsync( NpgsqlConnection connection, string sql, diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index e1e6dfc..30c58fc 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -7,6 +7,12 @@ public sealed class PortfolioMigrationTests { var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql"); var normalizedMigration = NormalizeSql(migration); + var unpublishFunctionStart = normalizedMigration.IndexOf( + "CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()", + StringComparison.Ordinal); + var unpublishFunctionEnd = normalizedMigration.IndexOf( + "CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule", + StringComparison.Ordinal); Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal); Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal); @@ -40,11 +46,16 @@ public sealed class PortfolioMigrationTests 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", 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 FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at AND NEW.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal); - Assert.Contains("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;", 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("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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());", 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 FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); - Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); + Assert.DoesNotContain( + "pg_advisory_xact_lock", + normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd], + StringComparison.Ordinal); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs index 44fa0c6..7a593fa 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs @@ -17,7 +17,7 @@ public sealed class PortfolioSchemaGateSourceTests var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); Assert.Contains( - "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\") .WithHttpHealthCheck(\"/health\", endpointName: \"health\");", + "var bot = builder.AddProject(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\", isProxied: false) .WithHttpHealthCheck(\"/health\", endpointName: \"health\");", appHost, StringComparison.Ordinal); Assert.Contains( @@ -30,6 +30,14 @@ public sealed class PortfolioSchemaGateSourceTests StringComparison.Ordinal); } + [Fact] + public async Task Aspire_ShouldUseApplicationDatabaseConnectionStringName() + { + var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs")); + + Assert.Contains(".AddDatabase(\"gmrelaydb\");", appHost, StringComparison.Ordinal); + } + private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) { var serviceBlock = GetServiceBlock(compose, serviceName); -- 2.52.0 From 1d62f69ff03727776a2e7d52b9f1de015e306c08 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 07:10:37 +0300 Subject: [PATCH 18/31] fix(data): lock racing portfolio publications --- .../2026-05-30-completed-game-portfolio.md | 39 +++++++++++++++---- ...6-05-30-completed-game-portfolio-design.md | 2 +- ..._completed_game_portfolios_and_reviews.sql | 3 +- .../Web/PortfolioMigrationPostgresTests.cs | 18 +++++---- .../Web/PortfolioMigrationTests.cs | 1 + 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 32f4b0e..ec14b69 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -77,7 +77,8 @@ - `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` +- `d762ecc` `fix(data): serialize portfolio future reschedules` +- Current fix cycle: `fix(data): lock racing portfolio publications` **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` @@ -413,16 +414,38 @@ CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + final_scheduled_at TIMESTAMPTZ; BEGIN - IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at - AND NEW.scheduled_at >= now() THEN + SELECT s.scheduled_at + INTO final_scheduled_at + FROM sessions s + WHERE s.id = NEW.id; + + IF final_scheduled_at >= now() THEN + 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; + 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; + 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; @@ -485,7 +508,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending 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. +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 cards linked to any final-future session in `portfolio_games.id` order before one guarded public-card unpublish update. The 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. 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** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index aa7f5be..15372a9 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all currently public cards linked to any final-future session in `portfolio_games.id` order, then unpublishes the matching cards in one guarded update. The low-volume global pass gives batch reschedules one card-lock order without taking the publication validator advisory lock before card locks. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts, then unpublishes the matching public cards in one guarded update. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. The low-volume global pass gives batch reschedules one card-lock order without taking the publication validator advisory lock before card locks. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index dd3fb39..757c1a6 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -130,8 +130,7 @@ BEGIN IF final_scheduled_at >= now() THEN PERFORM pg.id FROM portfolio_games pg - WHERE pg.is_public = true - AND EXISTS ( + WHERE EXISTS ( SELECT 1 FROM portfolio_game_sessions pgs JOIN sessions s ON s.id = pgs.session_id diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 1bcef04..37b442b 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -497,9 +497,12 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi var database = await fixture.CreateMigratedDatabaseAsync(); await using var publishConnection = await database.OpenConnectionAsync(); await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(publishConnection, isPublic: false); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); await ExecuteNonQueryAsync( publishConnection, @@ -518,14 +521,15 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi rescheduleTransaction, new NpgsqlParameter("sessionId", seed.SessionIds[0])); - var commitStates = await Task.WhenAll( - CommitAndCaptureSqlStateAsync(publishTransaction), - CommitAndCaptureSqlStateAsync(rescheduleTransaction)).WaitAsync(CommandTimeout); + var forceRescheduleTriggerTask = ExecuteNonQueryAsync( + rescheduleConnection, + "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", + rescheduleTransaction); + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, publishPid); - Assert.True( - commitStates[0] is null or PostgresErrorCodes.CheckViolation, - $"Unexpected publish SQLSTATE: {commitStates[0] ?? ""}."); - Assert.Null(commitStates[1]); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.False(await ExecuteScalarAsync( diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 30c58fc..165bf09 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -48,6 +48,7 @@ public sealed class PortfolioMigrationTests 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("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal); Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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());", 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 FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); -- 2.52.0 From ea714480d3d67c1e97abfbcec11a639cfb7ae7a0 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 07:31:35 +0300 Subject: [PATCH 19/31] fix(data): serialize new-link publication races --- .../2026-05-30-completed-game-portfolio.md | 14 ++- ...6-05-30-completed-game-portfolio-design.md | 4 +- ..._completed_game_portfolios_and_reviews.sql | 2 + .../Web/PortfolioMigrationPostgresTests.cs | 89 +++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 6 +- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index ec14b69..e0cb76c 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -78,7 +78,8 @@ - `da0a306` `fix(data): enforce completed portfolio sessions` - `a28b75d` `fix(data): align portfolio mutation lock order` - `d762ecc` `fix(data): serialize portfolio future reschedules` -- Current fix cycle: `fix(data): lock racing portfolio publications` +- `1d62f69` `fix(data): lock racing portfolio publications` +- Current fix cycle: `fix(data): serialize new-link publication races` **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` @@ -267,6 +268,9 @@ public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit [Fact] public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard() +[Fact] +public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard() + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -281,7 +285,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s 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. +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 races must finish with the future session committed and the card private, including a new-link draft publication forced behind the post-row-lock advisory gate. 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** @@ -435,6 +439,8 @@ BEGIN 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() @@ -508,7 +514,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending 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 cards linked to any final-future session in `portfolio_games.id` order before one guarded public-card unpublish update. The 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. 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. +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 cards linked to any final-future session in `portfolio_games.id` order. It then acquires the publication advisory lock and runs one guarded public-card unpublish update with a fresh `READ COMMITTED` 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; the post-row-lock advisory phase also serializes a previously invisible concurrent link-add publication without moving the advisory lock above 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** @@ -554,7 +560,7 @@ 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. +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, 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, 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** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 15372a9..f964ff8 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts, then unpublishes the matching public cards in one guarded update. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. The low-volume global pass gives batch reschedules one card-lock order without taking the publication validator advisory lock before card locks. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh `READ COMMITTED` statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Taking the shared advisory lock after card rows, but before the guarded update, also serializes a previously invisible concurrent link-add publication without reintroducing the card/advisory lock inversion. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -356,7 +356,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 757c1a6..4eab31f 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -140,6 +140,8 @@ BEGIN 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() diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 37b442b..6286fab 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -542,6 +542,95 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]))); } + [Fact] + public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var rescheduledSessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", rescheduledSessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); + + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + rescheduledSessionId)); + var forceRescheduleTriggerTask = ExecuteNonQueryAsync( + rescheduleConnection, + "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", + rescheduleTransaction); + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, gatePid); + + await ExecuteNonQueryAsync( + publishConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId; + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId)); + var publishCommitTask = CommitAndCaptureSqlStateAsync(publishTransaction); + await WaitUntilBlockedByAsync(observerConnection, publishPid, gatePid); + + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); + await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(PostgresErrorCodes.CheckViolation, await publishCommitTask.WaitAsync(CommandTimeout)); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", rescheduledSessionId))); + Assert.Equal(0, await ExecuteScalarAsync( + verificationConnection, + """ + SELECT COUNT(*) + FROM portfolio_game_sessions + WHERE portfolio_game_id = @portfolioGameId + AND session_id = @sessionId + """, + parameters: + [ + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId) + ])); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index 165bf09..d1f9ce5 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -50,13 +50,11 @@ public sealed class PortfolioMigrationTests Assert.Contains("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal); Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal); Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg; PERFORM pg_advisory_xact_lock(20260530, 108); UPDATE portfolio_games pg SET is_public = false", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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());", 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 FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); - Assert.DoesNotContain( - "pg_advisory_xact_lock", - normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd], - StringComparison.Ordinal); + Assert.Contains("pg_advisory_xact_lock", normalizedMigration[unpublishFunctionStart..unpublishFunctionEnd], StringComparison.Ordinal); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.Contains("publication_consent_at TIMESTAMPTZ NOT NULL,", normalizedMigration, StringComparison.Ordinal); } -- 2.52.0 From 85918c1e5d9e287da4c0b0de8e95cf6a5f3eb398 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 07:31:54 +0300 Subject: [PATCH 20/31] docs: sync portfolio task 1 review index --- docs/superpowers/plans/2026-05-30-completed-game-portfolio.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index e0cb76c..a7e9a24 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -79,7 +79,7 @@ - `a28b75d` `fix(data): align portfolio mutation lock order` - `d762ecc` `fix(data): serialize portfolio future reschedules` - `1d62f69` `fix(data): lock racing portfolio publications` -- Current fix cycle: `fix(data): serialize new-link publication races` +- `ea71448` `fix(data): serialize new-link publication races` **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` -- 2.52.0 From 1a8161027c3927859fa40df317dd9353228b3de9 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 07:57:30 +0300 Subject: [PATCH 21/31] fix(data): reject stale reschedule snapshots --- .../2026-05-30-completed-game-portfolio.md | 16 +++- ...6-05-30-completed-game-portfolio-design.md | 4 +- ..._completed_game_portfolios_and_reviews.sql | 6 ++ .../Web/PortfolioMigrationPostgresTests.cs | 75 +++++++++++++++++++ .../Web/PortfolioMigrationTests.cs | 1 + 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index a7e9a24..4dd33dd 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -80,6 +80,7 @@ - `d762ecc` `fix(data): serialize portfolio future reschedules` - `1d62f69` `fix(data): lock racing portfolio publications` - `ea71448` `fix(data): serialize new-link publication races` +- Current fix cycle: `fix(data): reject stale reschedule snapshots` **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` @@ -271,6 +272,9 @@ public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommit [Fact] public async Task ConcurrentNewLinkPublishAndFutureReschedule_ShouldNotCommitInvalidPublicCard() +[Fact] +public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard() + [Theory] [InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_masters")] @@ -285,7 +289,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s 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 races must finish with the future session committed and the card private, including a new-link draft publication forced behind the post-row-lock advisory gate. 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. +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 races must finish with the future session committed and the card private, including a new-link draft publication forced behind the post-row-lock advisory gate. 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** @@ -427,6 +431,12 @@ BEGIN 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 ( @@ -514,7 +524,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending 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 cards linked to any final-future session in `portfolio_games.id` order. It then acquires the publication advisory lock and runs one guarded public-card unpublish update with a fresh `READ COMMITTED` 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; the post-row-lock advisory phase also serializes a previously invisible concurrent link-add publication without moving the advisory lock above 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. +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 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. It then acquires the publication 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; the post-row-lock advisory phase also serializes a previously invisible concurrent link-add publication without moving the advisory lock above 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** @@ -560,7 +570,7 @@ 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, 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, 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. +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, 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** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index f964ff8..083627f 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -81,7 +81,7 @@ Application validation additionally requires at least one linked session, every Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. For a final future value it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh `READ COMMITTED` statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Taking the shared advisory lock after card rows, but before the guarded update, also serializes a previously invisible concurrent link-add publication without reintroducing the card/advisory lock inversion. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Taking the shared advisory lock after card rows, but before the guarded update, also serializes a previously invisible concurrent link-add publication without reintroducing the card/advisory lock inversion. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -356,7 +356,7 @@ Follow TDD for production changes. ### Schema And Contracts - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 4eab31f..7e6334d 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -128,6 +128,12 @@ BEGIN 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 ( diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 6286fab..2ae9621 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -631,6 +631,81 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi ])); } + [Fact] + public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var rescheduledSessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", rescheduledSessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var publishConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + + Assert.Equal(0, await ExecuteScalarAsync( + rescheduleConnection, + """ + SELECT COUNT(*) + FROM portfolio_game_sessions + WHERE portfolio_game_id = @portfolioGameId + AND session_id = @sessionId + """, + rescheduleTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId))); + + await using (var publishTransaction = await publishConnection.BeginTransactionAsync()) + { + await ExecuteNonQueryAsync( + publishConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + publishTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", rescheduledSessionId)); + await publishTransaction.CommitAsync().WaitAsync(CommandTimeout); + } + + Assert.Equal(1, await RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + rescheduledSessionId)); + + var exception = await Assert.ThrowsAsync( + () => rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout)); + + Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); + + await using var verificationConnection = await database.OpenConnectionAsync(); + Assert.True(await ExecuteScalarAsync( + verificationConnection, + "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", + parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + Assert.False(await ExecuteScalarAsync( + verificationConnection, + "SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId", + parameters: new NpgsqlParameter("sessionId", rescheduledSessionId))); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index d1f9ce5..bd6ad29 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -48,6 +48,7 @@ public sealed class PortfolioMigrationTests 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("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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", normalizedMigration, StringComparison.Ordinal); Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal); Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg; PERFORM pg_advisory_xact_lock(20260530, 108); UPDATE portfolio_games pg SET is_public = false", normalizedMigration, StringComparison.Ordinal); -- 2.52.0 From edf40c9a09556f9270817822591513d7c403fe31 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 07:57:46 +0300 Subject: [PATCH 22/31] docs: sync portfolio task 1 review index --- docs/superpowers/plans/2026-05-30-completed-game-portfolio.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 4dd33dd..ed44439 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -80,7 +80,7 @@ - `d762ecc` `fix(data): serialize portfolio future reschedules` - `1d62f69` `fix(data): lock racing portfolio publications` - `ea71448` `fix(data): serialize new-link publication races` -- Current fix cycle: `fix(data): reject stale reschedule snapshots` +- `1a81610` `fix(data): reject stale reschedule snapshots` **Files:** - Create: `tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs` -- 2.52.0 From a20da4b1a0acbb5042f66d1220c10cac59eb5b2b Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 10:32:13 +0300 Subject: [PATCH 23/31] fix(data): serialize portfolio mutations before rows --- .../2026-05-30-completed-game-portfolio.md | 82 +++- ...6-05-30-completed-game-portfolio-design.md | 8 +- ..._completed_game_portfolios_and_reviews.sql | 40 ++ .../Sessions/DiscordDeleteSessionHandler.cs | 3 + .../ListSessions/DeleteSessionHandler.cs | 11 +- .../Web/PortfolioMigrationPostgresTests.cs | 452 +++++++++--------- .../Web/PortfolioMigrationTests.cs | 7 + .../PortfolioSessionDeletionSourceTests.cs | 14 + 8 files changed, 379 insertions(+), 238 deletions(-) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index ed44439..302a1bc 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -124,6 +124,13 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards( 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); @@ -155,7 +162,7 @@ public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys() } ``` -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: +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] @@ -166,11 +173,17 @@ public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishing 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)); @@ -237,7 +250,7 @@ public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirs [Theory] [InlineData(true)] [InlineData(false)] -public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst) +public async Task ConcurrentPublishAndLinkDelete_ShouldSerializeBeforeRowsAndRejectInvalidPublicCard(bool publishMutatesFirst) [Theory] [InlineData("portfolio_game_sessions", "session_id")] @@ -261,7 +274,7 @@ public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndP public async Task PublishedCardPastFuturePastReschedule_ShouldRemainPublicAndPreserveFirstPublishedAt() [Fact] -public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock() +public async Task ConcurrentBatchFutureReschedules_ShouldSerializeBeforeSessionRowsWithoutDeadlock() [Fact] public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit() @@ -272,6 +285,17 @@ public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommit [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() @@ -289,7 +313,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s 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 races must finish with the future session committed and the card private, including a new-link draft publication forced behind the post-row-lock advisory gate. 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. +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** @@ -355,6 +379,46 @@ CREATE TABLE portfolio_game_masters ( 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 @@ -524,11 +588,11 @@ CREATE INDEX ix_portfolio_game_reviews_pending 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 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. It then acquires the publication 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; the post-row-lock advisory phase also serializes a previously invisible concurrent link-add publication without moving the advisory lock above 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. +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`, strengthen the initial session fetch with `FOR UPDATE OF s`. After authorization, run this statement inside the existing transaction before `DELETE FROM sessions`: +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 @@ -540,7 +604,7 @@ WHERE pgs.portfolio_game_id = pg.id 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: +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 @@ -556,7 +620,7 @@ WHERE pgs.portfolio_game_id = pg.id 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. +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. @@ -570,7 +634,7 @@ 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, 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. +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** diff --git a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md index 083627f..e062d9f 100644 --- a/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md +++ b/docs/superpowers/specs/2026-05-30-completed-game-portfolio-design.md @@ -79,9 +79,9 @@ CHECK (NOT is_public OR ( Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. -Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. +Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, 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. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. -A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Taking the shared advisory lock after card rows, but before the guarded update, also serializes a previously invisible concurrent link-add publication without reintroducing the card/advisory lock inversion. Session mutation paths use `sessions` before linked `portfolio_games`; normal session-deletion handlers explicitly lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. +A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. ### `portfolio_game_sessions` @@ -355,8 +355,8 @@ Follow TDD for production changes. ### Schema And Contracts -- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, session-first deletion locks, and the AppHost HTTP health gate. -- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit session-lock then unpublish then session deletion, delete/reschedule lock ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, opposing-order batch future reschedules without card deadlock using an observed advisory test gate instead of timing sleeps, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion. +- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate. +- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Existing showcase tests continue to assert the future-session catalog boundary. diff --git a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql index 7e6334d..bd34c44 100644 --- a/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql +++ b/src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql @@ -52,6 +52,46 @@ CREATE TABLE portfolio_game_masters ( 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 diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs index 97c5f5c..046a1b7 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs @@ -45,6 +45,9 @@ public sealed class DiscordDeleteSessionHandler( } await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + await connection.ExecuteAsync( + "SELECT pg_advisory_xact_lock(20260530, 108)", + transaction: transaction); _ = await connection.QuerySingleOrDefaultAsync( """ SELECT s.id diff --git a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs index 34b9b3f..fd69fc5 100644 --- a/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs @@ -31,7 +31,12 @@ public sealed class DeleteSessionHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); - // 1. Lock the session before any linked portfolio card and verify group manager. + // 1. Use the database mutation order before locking the session or linked portfolio cards. + await connection.ExecuteAsync( + "SELECT pg_advisory_xact_lock(20260530, 108)", + transaction: transaction); + + // 2. Lock the session before any linked portfolio card and verify group manager. var session = await connection.QuerySingleOrDefaultAsync( """ SELECT s.title AS Title, @@ -63,7 +68,7 @@ public sealed class DeleteSessionHandler( return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0); } - // 2. Unpublish a linked portfolio card before its required session link cascades away. + // 3. Unpublish a linked portfolio card before its required session link cascades away. await connection.ExecuteAsync( """ UPDATE portfolio_games pg @@ -77,7 +82,7 @@ public sealed class DeleteSessionHandler( new { command.SessionId }, transaction); - // 3. Delete session + // 4. Delete session await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction); var remainingInTopic = session.ThreadId.HasValue diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs index 2ae9621..27e5eac 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs @@ -96,51 +96,64 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi [Theory] [InlineData(true)] [InlineData(false)] - public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst) + public async Task ConcurrentPublishAndLinkDelete_ShouldSerializeBeforeRowsAndRejectInvalidPublicCard( + bool publishMutatesFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var publishConnection = await database.OpenConnectionAsync(); await using var deleteConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(publishConnection, isPublic: false); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); - await ExecuteNonQueryAsync( - publishConnection, - """ - UPDATE portfolio_games - SET is_public = true, - published_at = COALESCE(published_at, now()), - updated_at = now() - WHERE id = @portfolioGameId - """, - publishTransaction, - new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); - await ExecuteNonQueryAsync(deleteConnection, "SET LOCAL lock_timeout = '2s'", deleteTransaction); + if (publishMutatesFirst) + { + Assert.Equal(1, await PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId)); + var deleteTask = DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId); - Assert.Equal(1, await ExecuteNonQueryAsync( - deleteConnection, - "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", - deleteTransaction, - new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); + await WaitUntilBlockedByAsync(observerConnection, deletePid, publishPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await deleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + } + else + { + Assert.Equal(1, await DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId)); + var publishTask = PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId); - await AcquirePortfolioValidationLockAsync( - publishCommitsFirst ? publishConnection : deleteConnection, - publishCommitsFirst ? publishTransaction : deleteTransaction); - - var commitStates = await Task.WhenAll( - CommitAndCaptureSqlStateAsync(publishTransaction), - CommitAndCaptureSqlStateAsync(deleteTransaction)).WaitAsync(CommandTimeout); - - Assert.Equal(publishCommitsFirst ? null : PostgresErrorCodes.CheckViolation, commitStates[0]); - Assert.Equal(publishCommitsFirst ? PostgresErrorCodes.CheckViolation : null, commitStates[1]); + await WaitUntilBlockedByAsync(observerConnection, publishPid, deletePid); + Assert.Null(await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await publishTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + } await using var verificationConnection = await database.OpenConnectionAsync(); - Assert.Equal(publishCommitsFirst, await ExecuteScalarAsync( + Assert.Equal(publishMutatesFirst, await ExecuteScalarAsync( verificationConnection, "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); - Assert.Equal(publishCommitsFirst ? 1L : 0L, await ExecuteScalarAsync( + Assert.Equal(publishMutatesFirst ? 1L : 0L, await ExecuteScalarAsync( verificationConnection, "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); @@ -162,8 +175,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(); await using var secondTransaction = await secondConnection.BeginTransactionAsync(); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; await ExecuteNonQueryAsync( @@ -172,19 +188,19 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi firstTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[0])); - await ExecuteNonQueryAsync( + var secondDeleteTask = ExecuteNonQueryAsync( secondConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", secondTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[1])); - var commitStates = await Task.WhenAll( - CommitAndCaptureSqlStateAsync(firstTransaction), - CommitAndCaptureSqlStateAsync(secondTransaction)); - - Assert.Single(commitStates, state => state is null); - Assert.Single(commitStates, state => state == PostgresErrorCodes.CheckViolation); + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await secondDeleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( @@ -213,8 +229,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi masterCount: linkTable == "portfolio_game_masters" ? 2 : 1); await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); await using var secondTransaction = await secondConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); + var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); + var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds; await ExecuteNonQueryAsync( @@ -223,18 +242,21 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi firstTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[0])); - await ExecuteNonQueryAsync( + var secondDeleteTask = ExecuteNonQueryAsync( secondConnection, $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId", secondTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("linkId", linkIds[1])); - var commitStates = await Task.WhenAll( - CommitAndCaptureSqlStateAsync(firstTransaction), - CommitAndCaptureSqlStateAsync(secondTransaction)); - - Assert.All(commitStates, state => Assert.Equal(PostgresErrorCodes.FeatureNotSupported, state)); + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await secondDeleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( @@ -273,43 +295,57 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi [InlineData(true)] [InlineData(false)] public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard( - bool publishCommitsFirst) + bool deleteMutatesFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var seed = await SeedCardAsync(seedConnection, isPublic: false); await using var deleteConnection = await database.OpenConnectionAsync(); await using var publishConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); await using var deleteTransaction = await deleteConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); + var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); + var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); - await ExecuteNonQueryAsync( - deleteConnection, - "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", - deleteTransaction, - new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); - await ExecuteNonQueryAsync( - publishConnection, - """ - UPDATE portfolio_games - SET is_public = true, - published_at = COALESCE(published_at, now()), - updated_at = now() - WHERE id = @portfolioGameId - """, - publishTransaction, - new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); + if (deleteMutatesFirst) + { + Assert.Equal(1, await DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId)); + var publishTask = PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId); - await AcquirePortfolioValidationLockAsync( - publishCommitsFirst ? publishConnection : deleteConnection, - publishCommitsFirst ? publishTransaction : deleteTransaction); + await WaitUntilBlockedByAsync(observerConnection, publishPid, deletePid); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await publishTask.WaitAsync(CommandTimeout)); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + } + else + { + Assert.Equal(1, await PublishPortfolioGameAsync( + publishConnection, + publishTransaction, + seed.PortfolioGameId)); + var deleteTask = DeletePortfolioGameLinksAsync( + deleteConnection, + deleteTransaction, + "portfolio_game_sessions", + seed.PortfolioGameId); - var commitStates = await Task.WhenAll( - CommitAndCaptureSqlStateAsync(deleteTransaction), - CommitAndCaptureSqlStateAsync(publishTransaction)).WaitAsync(CommandTimeout); - - Assert.Equal(PostgresErrorCodes.FeatureNotSupported, commitStates[0]); - Assert.Null(commitStates[1]); + await WaitUntilBlockedByAsync(observerConnection, deletePid, publishPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(1, await deleteTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.FeatureNotSupported, + await CommitAndCaptureSqlStateAsync(deleteTransaction).WaitAsync(CommandTimeout)); + } await using var verificationConnection = await database.OpenConnectionAsync(); Assert.True(await ExecuteScalarAsync( @@ -376,74 +412,36 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi } [Fact] - public async Task ConcurrentBatchFutureReschedules_ShouldLockPublicCardsInStableOrderWithoutDeadlock() + public async Task ConcurrentBatchFutureReschedules_ShouldSerializeBeforeSessionRowsWithoutDeadlock() { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); var firstSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); var secondSeed = await SeedCardAsync(seedConnection, isPublic: true, sessionCount: 2); - await ExecuteNonQueryAsync( - seedConnection, - """ - CREATE FUNCTION wait_for_portfolio_card_unpublish_gate() - RETURNS TRIGGER - LANGUAGE plpgsql - AS $$ - BEGIN - PERFORM pg_advisory_xact_lock(20260601, 108); - RETURN NULL; - END; - $$; - - CREATE TRIGGER trg_wait_for_portfolio_card_unpublish_gate - AFTER UPDATE OF is_public ON portfolio_games - FOR EACH ROW - WHEN (OLD.is_public = true AND NEW.is_public = false) - EXECUTE FUNCTION wait_for_portfolio_card_unpublish_gate(); - """); - await using var firstConnection = await database.OpenConnectionAsync(); await using var secondConnection = await database.OpenConnectionAsync(); - await using var gateConnection = await database.OpenConnectionAsync(); await using var observerConnection = await database.OpenConnectionAsync(); await using var firstTransaction = await firstConnection.BeginTransactionAsync(); await using var secondTransaction = await secondConnection.BeginTransactionAsync(); - await using var gateTransaction = await gateConnection.BeginTransactionAsync(); var firstPid = await GetBackendPidAsync(firstConnection, firstTransaction); var secondPid = await GetBackendPidAsync(secondConnection, secondTransaction); - var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); - await AcquireBatchRescheduleGateAsync(gateConnection, gateTransaction); await RescheduleSessionsAsync( firstConnection, firstTransaction, firstSeed.SessionIds[0], secondSeed.SessionIds[0]); - await RescheduleSessionsAsync( + var secondRescheduleTask = RescheduleSessionsAsync( secondConnection, secondTransaction, secondSeed.SessionIds[1], firstSeed.SessionIds[1]); - var firstCommitTask = CommitAndCaptureSqlStateAsync(firstTransaction); - var secondCommitTask = CommitAndCaptureSqlStateAsync(secondTransaction); - var gateBlockedPid = await WaitUntilEitherBlockedByAsync( - observerConnection, - firstPid, - secondPid, - gatePid); - await WaitUntilBlockedByAnyAsync( - observerConnection, - gateBlockedPid == firstPid ? secondPid : firstPid, - gatePid, - gateBlockedPid); - - await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); - - var commitStates = await Task.WhenAll(firstCommitTask, secondCommitTask).WaitAsync(CommandTimeout); - - Assert.All(commitStates, Assert.Null); + await WaitUntilBlockedByAsync(observerConnection, secondPid, firstPid); + Assert.Null(await CommitAndCaptureSqlStateAsync(firstTransaction).WaitAsync(CommandTimeout)); + Assert.Equal(2, await secondRescheduleTask.WaitAsync(CommandTimeout)); + Assert.Null(await CommitAndCaptureSqlStateAsync(secondTransaction).WaitAsync(CommandTimeout)); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.Equal(0, await ExecuteScalarAsync( @@ -504,31 +502,19 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); - await ExecuteNonQueryAsync( + Assert.Equal(1, await PublishPortfolioGameAsync( publishConnection, - """ - UPDATE portfolio_games - SET is_public = true, - published_at = COALESCE(published_at, now()), - updated_at = now() - WHERE id = @portfolioGameId - """, publishTransaction, - new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); - await ExecuteNonQueryAsync( + seed.PortfolioGameId)); + var rescheduleTask = ExecuteNonQueryAsync( rescheduleConnection, "UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId", rescheduleTransaction, new NpgsqlParameter("sessionId", seed.SessionIds[0])); - var forceRescheduleTriggerTask = ExecuteNonQueryAsync( - rescheduleConnection, - "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", - rescheduleTransaction); await WaitUntilBlockedByAsync(observerConnection, reschedulePid, publishPid); - Assert.Null(await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); - await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); + Assert.Equal(1, await rescheduleTask.WaitAsync(CommandTimeout)); await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); await using var verificationConnection = await database.OpenConnectionAsync(); @@ -563,15 +549,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi await using var rescheduleConnection = await database.OpenConnectionAsync(); await using var publishConnection = await database.OpenConnectionAsync(); - await using var gateConnection = await database.OpenConnectionAsync(); await using var observerConnection = await database.OpenConnectionAsync(); await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); await using var publishTransaction = await publishConnection.BeginTransactionAsync(); - await using var gateTransaction = await gateConnection.BeginTransactionAsync(); var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); var publishPid = await GetBackendPidAsync(publishConnection, publishTransaction); - var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); - await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); Assert.Equal(1, await RescheduleSessionAsync( rescheduleConnection, @@ -581,9 +563,9 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi rescheduleConnection, "SET CONSTRAINTS trg_sessions_unpublish_public_portfolio_games_for_future_reschedule IMMEDIATE", rescheduleTransaction); - await WaitUntilBlockedByAsync(observerConnection, reschedulePid, gatePid); + await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); - await ExecuteNonQueryAsync( + var publishMutationTask = ExecuteNonQueryAsync( publishConnection, """ INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) @@ -598,14 +580,13 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi publishTransaction, new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), new NpgsqlParameter("sessionId", rescheduledSessionId)); - var publishCommitTask = CommitAndCaptureSqlStateAsync(publishTransaction); - await WaitUntilBlockedByAsync(observerConnection, publishPid, gatePid); - - await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); - await forceRescheduleTriggerTask.WaitAsync(CommandTimeout); + await WaitUntilBlockedByAsync(observerConnection, publishPid, reschedulePid); await rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout); + await publishMutationTask.WaitAsync(CommandTimeout); - Assert.Equal(PostgresErrorCodes.CheckViolation, await publishCommitTask.WaitAsync(CommandTimeout)); + Assert.Equal( + PostgresErrorCodes.CheckViolation, + await CommitAndCaptureSqlStateAsync(publishTransaction).WaitAsync(CommandTimeout)); await using var verificationConnection = await database.OpenConnectionAsync(); Assert.False(await ExecuteScalarAsync( @@ -631,6 +612,78 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi ])); } + [Fact] + public async Task PortfolioSessionLinkInsert_ShouldAcquirePublicationLockBeforeRows() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: false); + var sessionId = Guid.NewGuid(); + await ExecuteNonQueryAsync( + seedConnection, + """ + INSERT INTO sessions (id, group_id, title, join_link, scheduled_at) + VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day'); + """, + parameters: + [ + new NpgsqlParameter("sessionId", sessionId), + new NpgsqlParameter("groupId", seed.GroupId) + ]); + + await using var insertConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var insertTransaction = await insertConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var insertPid = await GetBackendPidAsync(insertConnection, insertTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); + + var insertTask = ExecuteNonQueryAsync( + insertConnection, + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + VALUES (@portfolioGameId, @sessionId); + """, + insertTransaction, + new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId), + new NpgsqlParameter("sessionId", sessionId)); + + await WaitUntilBlockedByAsync(observerConnection, insertPid, gatePid); + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(1, await insertTask.WaitAsync(CommandTimeout)); + await insertTransaction.RollbackAsync().WaitAsync(CommandTimeout); + } + + [Fact] + public async Task FutureReschedule_ShouldAcquirePublicationLockBeforeSessionRows() + { + var database = await fixture.CreateMigratedDatabaseAsync(); + await using var seedConnection = await database.OpenConnectionAsync(); + var seed = await SeedCardAsync(seedConnection, isPublic: true); + await using var rescheduleConnection = await database.OpenConnectionAsync(); + await using var gateConnection = await database.OpenConnectionAsync(); + await using var observerConnection = await database.OpenConnectionAsync(); + await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(); + await using var gateTransaction = await gateConnection.BeginTransactionAsync(); + var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); + var gatePid = await GetBackendPidAsync(gateConnection, gateTransaction); + await AcquirePortfolioValidationLockAsync(gateConnection, gateTransaction); + + var rescheduleTask = RescheduleSessionAsync( + rescheduleConnection, + rescheduleTransaction, + seed.SessionIds[0]); + + await WaitUntilBlockedByAsync(observerConnection, reschedulePid, gatePid); + await gateTransaction.CommitAsync().WaitAsync(CommandTimeout); + + Assert.Equal(1, await rescheduleTask.WaitAsync(CommandTimeout)); + await rescheduleTransaction.RollbackAsync().WaitAsync(CommandTimeout); + } + [Fact] public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard() { @@ -709,8 +762,8 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi [Theory] [InlineData(true)] [InlineData(false)] - public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeSessionBeforeCardWithoutDeadlock( - bool deleteLocksSessionFirst) + public async Task ConcurrentSessionDeleteAndFutureReschedule_ShouldSerializeMutationGateBeforeRowsWithoutDeadlock( + bool deleteMutatesFirst) { var database = await fixture.CreateMigratedDatabaseAsync(); await using var seedConnection = await database.OpenConnectionAsync(); @@ -723,8 +776,9 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi var deletePid = await GetBackendPidAsync(deleteConnection, deleteTransaction); var reschedulePid = await GetBackendPidAsync(rescheduleConnection, rescheduleTransaction); - if (deleteLocksSessionFirst) + if (deleteMutatesFirst) { + await AcquirePortfolioValidationLockAsync(deleteConnection, deleteTransaction); await LockSessionAsync(deleteConnection, deleteTransaction, seed.SessionIds[0]); var rescheduleTask = RescheduleSessionAsync( rescheduleConnection, @@ -967,16 +1021,6 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi transaction); } - private static Task AcquireBatchRescheduleGateAsync( - NpgsqlConnection connection, - NpgsqlTransaction transaction) - { - return ExecuteNonQueryAsync( - connection, - "SELECT pg_advisory_xact_lock(20260601, 108)", - transaction); - } - private static Task GetBackendPidAsync( NpgsqlConnection connection, NpgsqlTransaction transaction) @@ -996,6 +1040,37 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi new NpgsqlParameter("sessionId", sessionId)); } + private static Task PublishPortfolioGameAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + Guid portfolioGameId) + { + return ExecuteNonQueryAsync( + connection, + """ + UPDATE portfolio_games + SET is_public = true, + published_at = COALESCE(published_at, now()), + updated_at = now() + WHERE id = @portfolioGameId + """, + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + + private static Task DeletePortfolioGameLinksAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string linkTable, + Guid portfolioGameId) + { + return ExecuteNonQueryAsync( + connection, + $"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId", + transaction, + new NpgsqlParameter("portfolioGameId", portfolioGameId)); + } + private static Task RescheduleSessionAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, @@ -1036,6 +1111,7 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi Guid portfolioGameId, Guid sessionId) { + await AcquirePortfolioValidationLockAsync(connection, transaction); await LockSessionAsync(connection, transaction, sessionId); await UnpublishAndDeleteSessionAsync(connection, transaction, portfolioGameId, sessionId); await transaction.CommitAsync().WaitAsync(CommandTimeout); @@ -1091,74 +1167,6 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi $"PostgreSQL backend {blockedPid} was not blocked by backend {blockingPid} within {CommandTimeout}."); } - private static async Task WaitUntilEitherBlockedByAsync( - NpgsqlConnection observerConnection, - int firstBlockedPid, - int secondBlockedPid, - int blockingPid) - { - using var timeout = new CancellationTokenSource(CommandTimeout); - while (!timeout.IsCancellationRequested) - { - var blockedPid = await ExecuteScalarAsync( - observerConnection, - """ - SELECT CASE - WHEN @blockingPid = ANY (pg_blocking_pids(@firstBlockedPid)) THEN @firstBlockedPid - WHEN @blockingPid = ANY (pg_blocking_pids(@secondBlockedPid)) THEN @secondBlockedPid - ELSE 0 - END - """, - parameters: - [ - new NpgsqlParameter("firstBlockedPid", firstBlockedPid), - new NpgsqlParameter("secondBlockedPid", secondBlockedPid), - new NpgsqlParameter("blockingPid", blockingPid) - ]); - if (blockedPid != 0) - { - return blockedPid; - } - - await Task.Yield(); - } - - throw new TimeoutException( - $"Neither PostgreSQL backend {firstBlockedPid} nor {secondBlockedPid} was blocked by backend {blockingPid} within {CommandTimeout}."); - } - - private static async Task WaitUntilBlockedByAnyAsync( - NpgsqlConnection observerConnection, - int blockedPid, - int firstBlockingPid, - int secondBlockingPid) - { - using var timeout = new CancellationTokenSource(CommandTimeout); - while (!timeout.IsCancellationRequested) - { - if (await ExecuteScalarAsync( - observerConnection, - """ - SELECT @firstBlockingPid = ANY (pg_blocking_pids(@blockedPid)) - OR @secondBlockingPid = ANY (pg_blocking_pids(@blockedPid)) - """, - parameters: - [ - new NpgsqlParameter("blockedPid", blockedPid), - new NpgsqlParameter("firstBlockingPid", firstBlockingPid), - new NpgsqlParameter("secondBlockingPid", secondBlockingPid) - ])) - { - return; - } - - await Task.Yield(); - } - - throw new TimeoutException( - $"PostgreSQL backend {blockedPid} was not blocked by backend {firstBlockingPid} or {secondBlockingPid} within {CommandTimeout}."); - } - private static async Task ExecuteNonQueryAsync( NpgsqlConnection connection, string sql, diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs index bd6ad29..28f42fa 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs @@ -36,6 +36,13 @@ public sealed class PortfolioMigrationTests Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_moderator ON portfolio_game_reviews (moderated_by_player_id) WHERE moderated_by_player_id IS NOT NULL;", normalizedMigration, StringComparison.Ordinal); Assert.Contains("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;", 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 EXECUTE FUNCTION lock_portfolio_publication_mutation();", 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 EXECUTE FUNCTION lock_portfolio_publication_mutation();", 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 EXECUTE FUNCTION lock_portfolio_publication_mutation();", 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 EXECUTE FUNCTION lock_portfolio_publication_mutation();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("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();", normalizedMigration, StringComparison.Ordinal); + Assert.Contains("CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete BEFORE DELETE ON players FOR EACH STATEMENT EXECUTE FUNCTION lock_portfolio_publication_mutation();", 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); diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs index f621caf..f480e1d 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs @@ -10,11 +10,18 @@ public sealed class PortfolioSessionDeletionSourceTests const string sessionLock = "FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s"; + const string advisoryLock = + "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(advisoryLock, source, StringComparison.Ordinal); Assert.Contains(sessionLock, source, StringComparison.Ordinal); Assert.Contains(unpublish, source, StringComparison.Ordinal); + Assert.True( + source.IndexOf(advisoryLock, StringComparison.Ordinal) < + source.IndexOf(sessionLock, StringComparison.Ordinal), + "The shared delete path must acquire the portfolio advisory lock before locking the session."); Assert.True( source.IndexOf(sessionLock, StringComparison.Ordinal) < source.IndexOf(unpublish, StringComparison.Ordinal), @@ -33,12 +40,19 @@ public sealed class PortfolioSessionDeletionSourceTests 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 advisoryLock = + "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 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(advisoryLock, source, StringComparison.Ordinal); 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(advisoryLock, StringComparison.Ordinal) < + source.IndexOf(sessionLock, StringComparison.Ordinal), + "The Discord delete path must acquire the portfolio advisory lock before locking the guild-scoped session."); Assert.True( source.IndexOf(sessionLock, StringComparison.Ordinal) < source.IndexOf(unpublish, StringComparison.Ordinal), -- 2.52.0 From 4af4e527785c535a086ef08177da1a7db9d67ed2 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 10:32:34 +0300 Subject: [PATCH 24/31] docs: sync portfolio task 1 review index --- docs/superpowers/plans/2026-05-30-completed-game-portfolio.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md index 302a1bc..76dc286 100644 --- a/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md +++ b/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md @@ -81,6 +81,7 @@ - `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` -- 2.52.0 From 7d1489445e467c666e8a7189401d68368cc5b2f5 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 12:08:05 +0300 Subject: [PATCH 25/31] feat(web): define portfolio contracts and validation --- .../Services/Portfolio/IPortfolioStore.cs | 36 +++++ .../Services/Portfolio/PortfolioContracts.cs | 90 +++++++++++ .../Services/Portfolio/PortfolioValidation.cs | 152 ++++++++++++++++++ .../Web/PortfolioContractsTests.cs | 120 ++++++++++++++ .../Web/PortfolioValidationTests.cs | 103 ++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs create mode 100644 src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs create mode 100644 src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs diff --git a/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs new file mode 100644 index 0000000..91f523a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs @@ -0,0 +1,36 @@ +namespace GmRelay.Web.Services.Portfolio; + +public interface IPortfolioStore +{ + Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug); + + Task> GetPublicPortfolioGamesForClubAsync(string clubSlug); + + Task GetPublicPortfolioGameBySlugAsync(string slug); + + Task> GetPortfolioGamesForGroupAsync(Guid groupId); + + Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId); + + Task GetPortfolioGameForManagementAsync(Guid portfolioGameId); + + Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId); + + Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId); + + Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId); + + Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update); + + Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey); + + Task 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 GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId); + + Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body); +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs new file mode 100644 index 0000000..60424a2 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs @@ -0,0 +1,90 @@ +namespace GmRelay.Web.Services.Portfolio; + +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 Masters, + IReadOnlyList Reviews); + +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 Sessions, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameUpdate( + string Title, + string? PublicSlug, + string? Description, + string? System, + string? Format, + IReadOnlyList SessionIds, + IReadOnlyList MasterPlayerIds); + +public enum PortfolioReviewSubmissionState +{ + RequiresAuthentication, + Ineligible, + Eligible, + AlreadySubmitted +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs new file mode 100644 index 0000000..c15c59f --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs @@ -0,0 +1,152 @@ +using System.Text; + +namespace GmRelay.Web.Services.Portfolio; + +public static class PortfolioValidation +{ + private const int MinSlugLength = 3; + private const int MaxSlugLength = 160; + private const int MinTitleLength = 2; + private const int MaxTitleLength = 255; + private const int MaxDescriptionLength = 5000; + private const int MinReviewBodyLength = 10; + private const int MaxReviewBodyLength = 2000; + + private static readonly HashSet AllowedFormats = new(StringComparer.Ordinal) + { + "Online", + "Offline", + "Hybrid" + }; + + public static string NormalizeSlug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Slug must not be empty."); + } + + var trimmed = value.Trim().ToLowerInvariant(); + + var builder = new StringBuilder(trimmed.Length); + var previousWasHyphen = false; + foreach (var raw in trimmed) + { + char c; + if (raw == ' ' || raw == '_' || raw == '-') + { + c = '-'; + } + else if (IsAsciiAlphanumeric(raw)) + { + c = raw; + } + else + { + throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'."); + } + + if (c == '-') + { + if (builder.Length == 0 || previousWasHyphen) + { + continue; + } + + builder.Append('-'); + previousWasHyphen = true; + } + else + { + builder.Append(c); + previousWasHyphen = false; + } + } + + while (builder.Length > 0 && builder[^1] == '-') + { + builder.Length--; + } + + if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength) + { + throw new InvalidOperationException( + $"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters."); + } + + // The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$, + // so no post-loop regex check is required. + return builder.ToString(); + } + + public static string NormalizeTitle(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Title must not be empty."); + } + + var trimmed = value.Trim(); + if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength) + { + throw new InvalidOperationException( + $"Title length must be between {MinTitleLength} and {MaxTitleLength} characters."); + } + + return trimmed; + } + + public static string? NormalizeDescription(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + if (trimmed.Length > MaxDescriptionLength) + { + throw new InvalidOperationException( + $"Description must be at most {MaxDescriptionLength} characters."); + } + + return trimmed; + } + + public static string NormalizeReviewBody(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Review body must not be empty."); + } + + var trimmed = value.Trim(); + if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength) + { + throw new InvalidOperationException( + $"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters."); + } + + return trimmed; + } + + public static string? NormalizeFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + if (!AllowedFormats.Contains(trimmed)) + { + throw new InvalidOperationException( + $"Format must be one of: {string.Join(", ", AllowedFormats)}."); + } + + return trimmed; + } + + private static bool IsAsciiAlphanumeric(char c) => + (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs new file mode 100644 index 0000000..569ebe5 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs @@ -0,0 +1,120 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioContractsTests +{ + [Fact] + public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioCard_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioCard).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioGame).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("Description", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + Assert.Contains("ClubName", names); + Assert.Contains("ClubSlug", names); + Assert.Contains("Masters", names); + Assert.Contains("Reviews", names); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioMaster).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("DisplayName", names); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioReview).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("AuthorDisplayName", names); + Assert.Contains("Body", names); + Assert.Contains("CreatedAt", names); + } + + [Fact] + public void IPortfolioStore_ShouldExposeAllRequiredMethods() + { + var interfaceType = typeof(IPortfolioStore); + var methodNames = interfaceType.GetMethods().Select(m => m.Name).ToArray(); + + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", methodNames); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", methodNames); + Assert.Contains("GetPublicPortfolioGameBySlugAsync", methodNames); + Assert.Contains("GetPortfolioGamesForGroupAsync", methodNames); + Assert.Contains("GetPortfolioGameGroupIdAsync", methodNames); + Assert.Contains("GetPortfolioGameForManagementAsync", methodNames); + Assert.Contains("GetEligibleCompletedSessionsAsync", methodNames); + Assert.Contains("GetPortfolioMasterOptionsAsync", methodNames); + Assert.Contains("CreatePortfolioDraftAsync", methodNames); + Assert.Contains("UpdatePortfolioDraftAsync", methodNames); + Assert.Contains("SetPortfolioCoverAsync", methodNames); + Assert.Contains("DeletePortfolioGameAsync", methodNames); + Assert.Contains("SetPortfolioPublicationAsync", methodNames); + Assert.Contains("ModeratePortfolioReviewAsync", methodNames); + Assert.Contains("GetReviewSubmissionStateAsync", methodNames); + Assert.Contains("SubmitPortfolioReviewAsync", methodNames); + } + + private static void AssertNoForbiddenPropertyNames() + { + var forbidden = new[] + { + "Id", "External", "Telegram", "Discord", "Moderator", + "StorageKey", "PhysicalPath", "JoinLink", "Session" + }; + + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + + foreach (var forbiddenFragment in forbidden) + { + Assert.DoesNotContain(names, name => name.Contains(forbiddenFragment, StringComparison.Ordinal)); + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs new file mode 100644 index 0000000..be26500 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs @@ -0,0 +1,103 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioValidationTests +{ + [Theory] + [InlineData(" Dragon Heist ", "dragon-heist")] + [InlineData("dragon_heist", "dragon-heist")] + [InlineData("Dragon Heist", "dragon-heist")] + [InlineData("dragon---heist", "dragon-heist")] + [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("кириллица")] + [InlineData("hello world!")] + [InlineData("---")] + public void NormalizeSlug_ShouldRejectInvalidSlug(string input) + { + Assert.Throws(() => PortfolioValidation.NormalizeSlug(input)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeReviewBody_ShouldRejectBlankText(string? body) + { + Assert.Throws(() => PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData("This is a perfectly valid review body for the portfolio entry that should pass.")] + [InlineData(" Another valid review body that meets the minimum length requirement. ")] + public void NormalizeReviewBody_ShouldTrimAndAcceptValidText(string body) + { + Assert.Equal(body.Trim(), PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData(" Hello World ")] + [InlineData("abc")] + public void NormalizeTitle_ShouldTrimAndAcceptValidText(string title) + { + Assert.Equal(title.Trim(), PortfolioValidation.NormalizeTitle(title)); + } + + [Theory] + [InlineData("")] + [InlineData("a")] + public void NormalizeTitle_ShouldRejectTooShort(string title) + { + Assert.Throws(() => PortfolioValidation.NormalizeTitle(title)); + } + + [Fact] + public void NormalizeDescription_ShouldReturnNullForWhitespace() + { + Assert.Null(PortfolioValidation.NormalizeDescription(null)); + Assert.Null(PortfolioValidation.NormalizeDescription("")); + Assert.Null(PortfolioValidation.NormalizeDescription(" ")); + } + + [Fact] + public void NormalizeDescription_ShouldTrimAndAcceptValidText() + { + Assert.Equal("hello", PortfolioValidation.NormalizeDescription(" hello ")); + } + + [Theory] + [InlineData("Online")] + [InlineData("Offline")] + [InlineData("Hybrid")] + public void NormalizeFormat_ShouldAcceptKnownValues(string format) + { + Assert.Equal(format, PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeFormat_ShouldReturnNullForBlank(string? format) + { + Assert.Null(PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("online")] + [InlineData("VTT")] + public void NormalizeFormat_ShouldRejectUnknownValues(string format) + { + Assert.Throws(() => PortfolioValidation.NormalizeFormat(format)); + } +} -- 2.52.0 From e5945288ac101e5ff0cd05b759d2396202275e82 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 12:35:00 +0300 Subject: [PATCH 26/31] feat(web): add local portfolio cover storage --- .env.example | 3 + compose.yaml | 4 + src/GmRelay.Web/Dockerfile | 3 +- src/GmRelay.Web/Program.cs | 4 + .../Covers/IPortfolioCoverStorage.cs | 15 ++ .../Covers/LocalPortfolioCoverStorage.cs | 209 ++++++++++++++++++ .../Covers/PortfolioCoverStorageExtensions.cs | 82 +++++++ .../Covers/PortfolioCoverStorageOptions.cs | 8 + src/GmRelay.Web/appsettings.Development.json | 3 + .../Web/LocalPortfolioCoverStorageTests.cs | 172 ++++++++++++++ .../Web/PortfolioCoverRuntimeWiringTests.cs | 73 ++++++ 11 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs create mode 100644 src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs create mode 100644 src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs create mode 100644 src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs diff --git a/.env.example b/.env.example index 47db44c..0edcd37 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7 # Имя Docker volume для резервных копий БД BACKUP_VOLUME_NAME=game_pgbackups + +# Имя Docker volume для обложек портфолио (загружаемых мастерами) +PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers diff --git a/compose.yaml b/compose.yaml index 0ed1924..bdaee9a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -101,10 +101,12 @@ services: - "Discord__ClientId=${DISCORD_CLIENT_ID:-}" - "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}" - "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}" + - "PortfolioCovers__StoragePath=/app/portfolio-covers" ports: - "${GMRELAY_WEB_PORT:-8080}:8080" volumes: - web_keys:/app/dataprotection-keys + - portfolio_covers:/app/portfolio-covers networks: - gmrelay healthcheck: @@ -120,6 +122,8 @@ volumes: name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} pgbackups: name: ${BACKUP_VOLUME_NAME:-game_pgbackups} + portfolio_covers: + name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers} networks: gmrelay: diff --git a/src/GmRelay.Web/Dockerfile b/src/GmRelay.Web/Dockerfile index 970cd35..9394d78 100644 --- a/src/GmRelay.Web/Dockerfile +++ b/src/GmRelay.Web/Dockerfile @@ -20,7 +20,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/* COPY --from=build /app/publish . -RUN mkdir -p /app/dataprotection-keys && chown -R $APP_UID:$APP_UID /app/dataprotection-keys +RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \ + && chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 USER $APP_UID diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index f4308b0..790cf97 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -2,6 +2,7 @@ using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio.Covers; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; @@ -37,6 +38,7 @@ builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); +builder.Services.AddPortfolioCoverStorage(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("Discord")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -94,6 +96,8 @@ app.Use(async (context, next) => await next(); }); +app.UsePortfolioCoverFiles(); + app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs new file mode 100644 index 0000000..bea14c0 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs @@ -0,0 +1,15 @@ +namespace GmRelay.Web.Services.Portfolio.Covers; + +public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType); + +public interface IPortfolioCoverStorage +{ + Task SaveAsync( + Stream content, + string contentType, + CancellationToken cancellationToken = default); + + Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default); + + string GetPublicPath(string storageKey); +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs new file mode 100644 index 0000000..1a95d36 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs @@ -0,0 +1,209 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GmRelay.Web.Services.Portfolio.Covers; + +public sealed class LocalPortfolioCoverStorage : IPortfolioCoverStorage +{ + public const long MaxBytes = 5 * 1024 * 1024; + + private static readonly Regex SafeKeyPattern = new( + "^[a-f0-9]{32}\\.(jpg|png|webp)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF]; + private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + private static readonly byte[] RiffMarker = "RIFF"u8.ToArray(); + private static readonly byte[] WebpMarker = "WEBP"u8.ToArray(); + + private readonly string _storagePath; + private readonly ILogger _logger; + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options) + : this(options, logger: null) + { + } + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger? logger) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.StoragePath)) + { + throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured."); + } + + _storagePath = options.StoragePath; + _logger = logger ?? NullLogger.Instance; + } + + public async Task SaveAsync( + Stream content, + string contentType, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(content); + if (string.IsNullOrWhiteSpace(contentType)) + { + throw new InvalidOperationException("Content type must be provided."); + } + + var extension = NormalizeExtension(contentType); + + // Buffer the stream so we can reject oversize uploads before writing to disk + // and so we have the bytes we need for signature validation. + await using var buffer = new MemoryStream(); + await content.CopyToAsync(buffer, cancellationToken); + if (buffer.Length > MaxBytes) + { + throw new InvalidOperationException( + $"Cover image exceeds the {MaxBytes}-byte size limit."); + } + + var signature = buffer.GetBuffer(); + var signatureLength = (int)buffer.Length; + ValidateSignature(extension, signature, signatureLength); + + Directory.CreateDirectory(_storagePath); + var finalName = Guid.NewGuid().ToString("N") + extension; + var finalPath = Path.Combine(_storagePath, finalName); + var tempPath = finalPath + ".tmp"; + + try + { + await using (var tempStream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) + { + buffer.Position = 0; + await buffer.CopyToAsync(tempStream, cancellationToken); + await tempStream.FlushAsync(cancellationToken); + } + + File.Move(tempPath, finalPath, overwrite: false); + } + catch + { + TryDelete(tempPath); + throw; + } + + return new PortfolioCoverUploadResult(finalName, ResolveContentType(extension)); + } + + public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + EnsureSafeKey(storageKey); + + var path = Path.Combine(_storagePath, storageKey); + TryDelete(path); + return Task.CompletedTask; + } + + public string GetPublicPath(string storageKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + return "/portfolio-covers/" + Uri.EscapeDataString(storageKey); + } + + private static void ValidateSignature(string extension, byte[] data, int length) + { + var isValid = extension switch + { + ".jpg" => StartsWith(data, length, JpegSignature), + ".png" => StartsWith(data, length, PngSignature), + ".webp" => StartsWith(data, length, RiffMarker) + && ContainsAt(data, RiffMarker.Length + 4, WebpMarker), + _ => false + }; + + if (!isValid) + { + throw new InvalidOperationException( + $"Cover signature does not match the declared content type."); + } + } + + private static bool StartsWith(byte[] data, int length, byte[] prefix) + { + if (length < prefix.Length) + { + return false; + } + + for (var i = 0; i < prefix.Length; i++) + { + if (data[i] != prefix[i]) + { + return false; + } + } + + return true; + } + + private static bool ContainsAt(byte[] data, int offset, byte[] needle) + { + if (offset + needle.Length > data.Length) + { + return false; + } + + for (var i = 0; i < needle.Length; i++) + { + if (data[offset + i] != needle[i]) + { + return false; + } + } + + return true; + } + + private static string NormalizeExtension(string contentType) + { + var normalized = contentType.Trim().ToLowerInvariant(); + return normalized switch + { + "image/jpeg" or "image/jpg" => ".jpg", + "image/png" => ".png", + "image/webp" => ".webp", + _ => throw new InvalidOperationException( + $"Unsupported cover content type: '{contentType}'.") + }; + } + + private static string ResolveContentType(string extension) => extension switch + { + ".jpg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + _ => "application/octet-stream" + }; + + private static void EnsureSafeKey(string storageKey) + { + if (!SafeKeyPattern.IsMatch(storageKey)) + { + throw new InvalidOperationException("Cover storage key is not in the expected format."); + } + } + + private void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete cover file '{Path}'.", path); + } + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs new file mode 100644 index 0000000..a99c5a3 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GmRelay.Web.Services.Portfolio.Covers; + +public static class PortfolioCoverStorageExtensions +{ + public static IServiceCollection AddPortfolioCoverStorage( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services + .AddOptions() + .Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName)) + .Validate( + o => !string.IsNullOrWhiteSpace(o.StoragePath), + "PortfolioCovers:StoragePath must be configured.") + .ValidateOnStart(); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService< + Microsoft.Extensions.Options.IOptions>().Value; + var logger = sp.GetService()?.CreateLogger() + ?? NullLogger.Instance; + return new LocalPortfolioCoverStorage(options, logger); + }); + + return services; + } + + public static WebApplication UsePortfolioCoverFiles(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + var options = app.Services.GetRequiredService< + Microsoft.Extensions.Options.IOptions>().Value; + + var storagePath = Path.IsPathRooted(options.StoragePath) + ? options.StoragePath + : Path.Combine(app.Environment.ContentRootPath, options.StoragePath); + + Directory.CreateDirectory(storagePath); + + var contentTypeProvider = new FileExtensionContentTypeProvider(); + if (!contentTypeProvider.Mappings.ContainsKey(".jpg")) + { + contentTypeProvider.Mappings[".jpg"] = "image/jpeg"; + } + + if (!contentTypeProvider.Mappings.ContainsKey(".png")) + { + contentTypeProvider.Mappings[".png"] = "image/png"; + } + + if (!contentTypeProvider.Mappings.ContainsKey(".webp")) + { + contentTypeProvider.Mappings[".webp"] = "image/webp"; + } + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(storagePath), + RequestPath = "/portfolio-covers", + ContentTypeProvider = contentTypeProvider, + OnPrepareResponse = ctx => + { + ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } + }); + + return app; + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs new file mode 100644 index 0000000..79979a5 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs @@ -0,0 +1,8 @@ +namespace GmRelay.Web.Services.Portfolio.Covers; + +public sealed class PortfolioCoverStorageOptions +{ + public const string SectionName = "PortfolioCovers"; + + public string StoragePath { get; set; } = string.Empty; +} diff --git a/src/GmRelay.Web/appsettings.Development.json b/src/GmRelay.Web/appsettings.Development.json index 0c208ae..25f234b 100644 --- a/src/GmRelay.Web/appsettings.Development.json +++ b/src/GmRelay.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "PortfolioCovers": { + "StoragePath": "../../artifacts/portfolio-covers" } } diff --git a/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs new file mode 100644 index 0000000..bf7513d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs @@ -0,0 +1,172 @@ +using GmRelay.Web.Services.Portfolio.Covers; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class LocalPortfolioCoverStorageTests : IDisposable +{ + private readonly string _storagePath; + private readonly LocalPortfolioCoverStorage _storage; + + public LocalPortfolioCoverStorageTests() + { + _storagePath = Path.Combine( + Path.GetTempPath(), + "gmrelay-portfolio-covers-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_storagePath); + _storage = new LocalPortfolioCoverStorage(new PortfolioCoverStorageOptions + { + StoragePath = _storagePath + }); + } + + public void Dispose() + { + if (Directory.Exists(_storagePath)) + { + Directory.Delete(_storagePath, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey() + { + 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) + { + await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, contentType)); + } + + [Fact] + public async Task SaveAsync_ShouldPersistJpegWithCorrectSignature() + { + await using var stream = new MemoryStream( + [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]); + + var result = await _storage.SaveAsync(stream, "image/jpeg"); + + Assert.EndsWith(".jpg", result.StorageKey, StringComparison.Ordinal); + Assert.Equal("image/jpeg", result.ContentType); + Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey))); + } + + [Fact] + public async Task SaveAsync_ShouldPersistWebpWithRiffWebpSignature() + { + await using var stream = new MemoryStream( + [0x52, 0x49, 0x46, 0x46, 0x1A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38]); + + var result = await _storage.SaveAsync(stream, "image/webp"); + + Assert.EndsWith(".webp", result.StorageKey, StringComparison.Ordinal); + Assert.Equal("image/webp", result.ContentType); + Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey))); + } + + [Fact] + public async Task SaveAsync_ShouldRejectStreamLargerThanMaxBytes() + { + var oversized = new byte[LocalPortfolioCoverStorage.MaxBytes + 1]; + await using var stream = new MemoryStream(oversized); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, "image/png")); + } + + [Fact] + public async Task SaveAsync_ShouldRejectUnknownContentType() + { + await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]); + + await Assert.ThrowsAsync( + () => _storage.SaveAsync(stream, "application/octet-stream")); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRemoveExistingKey() + { + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + var result = await _storage.SaveAsync(stream, "image/png"); + var path = Path.Combine(_storagePath, result.StorageKey); + Assert.True(File.Exists(path)); + + await _storage.DeleteIfExistsAsync(result.StorageKey); + + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldBeNoOpForMissingKey() + { + var key = Guid.NewGuid().ToString("N") + ".png"; + + await _storage.DeleteIfExistsAsync(key); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRejectPathTraversal() + { + await Assert.ThrowsAsync( + () => _storage.DeleteIfExistsAsync("../escape.png")); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension() + { + await Assert.ThrowsAsync( + () => _storage.DeleteIfExistsAsync(Guid.NewGuid().ToString("N") + ".gif")); + } + + [Fact] + public void GetPublicPath_ShouldEscapeSpecialCharacters() + { + var key = "0123456789abcdef0123456789abcdef" + ".png"; + + var path = _storage.GetPublicPath(key); + + Assert.Equal("/portfolio-covers/" + Uri.EscapeDataString(key), path); + } + + [Fact] + public async Task SaveAsync_ShouldGenerateUniqueKeys() + { + await using var stream1 = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + await using var stream2 = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + var first = await _storage.SaveAsync(stream1, "image/png"); + var second = await _storage.SaveAsync(stream2, "image/png"); + + Assert.NotEqual(first.StorageKey, second.StorageKey); + } + + [Fact] + public async Task SaveAsync_ShouldNotLeaveTempFileBehind() + { + await using var stream = new MemoryStream( + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + await _storage.SaveAsync(stream, "image/png"); + + var tempFiles = Directory.GetFiles(_storagePath, "*.tmp"); + Assert.Empty(tempFiles); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs new file mode 100644 index 0000000..a7429e9 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs @@ -0,0 +1,73 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioCoverRuntimeWiringTests +{ + [Fact] + public async Task Program_ShouldRegisterPortfolioCoverStorage() + { + var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs"); + + Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal); + Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal); + } + + [Fact] + public async Task Compose_ShouldMountPortfolioCoversVolumeAndPassStoragePath() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal); + Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal); + } + + [Fact] + public async Task Dockerfile_ShouldCreateAndChownPortfolioCoversDirectory() + { + var dockerfile = await ReadRepositoryFileAsync("src/GmRelay.Web/Dockerfile"); + + 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); + } + + [Fact] + public async Task DevelopmentSettings_ShouldConfigurePortfolioCoversStoragePath() + { + var developmentSettings = await ReadRepositoryFileAsync("src/GmRelay.Web/appsettings.Development.json"); + + Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal); + } + + [Fact] + public async Task EnvExample_ShouldDocumentPortfolioCoversVolumeName() + { + var envExample = await ReadRepositoryFileAsync(".env.example"); + + Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", envExample, StringComparison.Ordinal); + } + + [Fact] + public async Task Compose_ShouldDeclarePortfolioCoversNamedVolume() + { + var compose = await ReadRepositoryFileAsync("compose.yaml"); + + Assert.Contains("portfolio_covers:", compose, StringComparison.Ordinal); + Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", compose, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} -- 2.52.0 From f2c9f34ab4e6f70c4f454beee7d83e0eb3183ece Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 13:04:41 +0300 Subject: [PATCH 27/31] feat(web): add portfolio persistence --- src/GmRelay.Web/Program.cs | 2 + .../Services/Portfolio/PortfolioService.cs | 1109 +++++++++++++++++ .../Web/PortfolioServiceSourceTests.cs | 95 ++ 3 files changed, 1206 insertions(+) create mode 100644 src/GmRelay.Web/Services/Portfolio/PortfolioService.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 790cf97..74030e5 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -2,6 +2,7 @@ using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio; using GmRelay.Web.Services.Portfolio.Covers; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; @@ -45,6 +46,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); // Add Bot Client builder.Services.AddSingleton(sp => diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs new file mode 100644 index 0000000..569d97a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioService.cs @@ -0,0 +1,1109 @@ +using Dapper; +using GmRelay.Shared.Domain; +using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio.Covers; +using Npgsql; +using System.Data; + +namespace GmRelay.Web.Services.Portfolio; + +public sealed class PortfolioService( + NpgsqlDataSource dataSource, + IPortfolioCoverStorage coverStorage) : IPortfolioStore +{ + // --- Public reads --- + + public async Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN portfolio_game_masters pgm ON pgm.portfolio_game_id = pg.id + JOIN players p ON p.id = pgm.player_id + JOIN master_profiles mp ON mp.player_id = p.id + WHERE pg.is_public = true + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + AND lower(mp.public_slug) = lower(@MasterSlug) + ORDER BY pg.completed_at DESC + """, + new { MasterSlug = masterSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND lower(g.public_slug) = lower(@ClubSlug) + ORDER BY pg.completed_at DESC + """, + new { ClubSlug = clubSlug }); + + return rows.Select(MapToPublicCard).ToList(); + } + + public async Task GetPublicPortfolioGameBySlugAsync(string slug) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var detail = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.public_slug AS Slug, + pg.title AS Title, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + COALESCE(NULLIF(g.name, g.external_group_id), NULL) AS ClubName, + CASE + WHEN g.public_schedule_enabled = true AND g.public_slug IS NOT NULL + THEN g.public_slug + ELSE NULL + END AS ClubSlug + FROM portfolio_games pg + JOIN game_groups g ON g.id = pg.group_id + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (detail is null) + { + return null; + } + + var masters = (await conn.QueryAsync( + """ + SELECT mp.public_slug AS Slug, + mp.display_name AS DisplayName + FROM portfolio_game_masters pgm + JOIN master_profiles mp ON mp.player_id = pgm.player_id + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND mp.is_public = true + AND mp.public_slug IS NOT NULL + ORDER BY mp.display_name + """, + new { PortfolioGameId = detail.Id })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.moderation_status = 'Approved' + AND r.publication_consent_at IS NOT NULL + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = detail.Id })).ToList(); + + return new PublicPortfolioGame( + detail.Slug!, + detail.Title, + detail.Description ?? string.Empty, + string.IsNullOrEmpty(detail.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(detail.CoverStorageKey), + detail.System, + detail.Format, + detail.CompletedAt, + detail.ClubSlug is null ? null : detail.ClubName, + detail.ClubSlug, + masters.Select(m => new PublicPortfolioMaster(m.Slug!, m.DisplayName)).ToList(), + reviews.Select(r => new PublicPortfolioReview(r.AuthorDisplayName, r.Body, r.CreatedAt)).ToList()); + } + + // --- Protected reads --- + + public async Task> GetPortfolioGamesForGroupAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.is_public AS IsPublic, + pg.completed_at AS CompletedAt, + COALESCE(session_counts.count, 0)::int AS SessionCount, + COALESCE(master_counts.count, 0)::int AS MasterCount, + COALESCE(pending_counts.count, 0)::int AS PendingReviewCount + FROM portfolio_games pg + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = pg.id + ) session_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = pg.id + ) master_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = pg.id + AND r.moderation_status = 'Pending' + ) pending_counts ON true + WHERE pg.group_id = @GroupId + ORDER BY pg.completed_at DESC, pg.created_at DESC + """, + new { GroupId = groupId }); + + return rows.Select(r => new PortfolioGameSummary( + r.Id, + r.GroupId, + r.Title, + r.PublicSlug, + r.IsPublic, + r.CompletedAt, + r.SessionCount, + r.MasterCount, + r.PendingReviewCount)).ToList(); + } + + public async Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT group_id FROM portfolio_games WHERE id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }); + } + + public async Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var header = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }); + + if (header is null) + { + return null; + } + + var sessions = (await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND pgs.session_id = s.id + ) AS Selected + FROM sessions s + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { PortfolioGameId = header.Id, GroupId = header.GroupId })).ToList(); + + var masters = (await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + EXISTS ( + SELECT 1 + FROM portfolio_game_masters pgm + WHERE pgm.portfolio_game_id = @PortfolioGameId + AND pgm.player_id = p.id + ) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + PortfolioGameId = header.Id, + GroupId = header.GroupId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + })).ToList(); + + var reviews = (await conn.QueryAsync( + """ + SELECT r.id AS Id, + r.author_display_name AS AuthorDisplayName, + r.body AS Body, + r.moderation_status AS ModerationStatus, + r.created_at AS CreatedAt + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + ORDER BY r.created_at DESC + """, + new { PortfolioGameId = header.Id })).ToList(); + + return new PortfolioGameEditor( + header.Id, + header.GroupId, + header.Title, + header.PublicSlug, + header.Description, + header.CoverStorageKey is null ? null : coverStorage.GetPublicPath(header.CoverStorageKey), + header.System, + header.Format, + header.CompletedAt, + header.IsPublic, + sessions.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(), + masters.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(), + reviews.Select(r => new PortfolioReviewForModeration(r.Id, r.AuthorDisplayName, r.Body, r.ModerationStatus, r.CreatedAt)).ToList()); + } + + public async Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + COALESCE(linked.Selected, false) AS Selected + FROM sessions s + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_sessions pgs + WHERE pgs.session_id = s.id + AND (@PortfolioGameId IS NULL OR pgs.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE s.group_id = @GroupId + AND s.scheduled_at < now() + ORDER BY s.scheduled_at DESC + """, + new { GroupId = groupId, PortfolioGameId = portfolioGameId }); + + return rows.Select(s => new PortfolioSessionOption(s.Id, s.Title, s.ScheduledAt, s.Selected)).ToList(); + } + + public async Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT p.id AS PlayerId, + p.display_name AS DisplayName, + COALESCE(linked.Selected, false) AS Selected + FROM group_managers gm + JOIN players p ON p.id = gm.player_id + LEFT JOIN LATERAL ( + SELECT true AS Selected + FROM portfolio_game_masters pgm + WHERE pgm.player_id = p.id + AND (@PortfolioGameId IS NULL OR pgm.portfolio_game_id = @PortfolioGameId) + ) linked ON true + WHERE gm.group_id = @GroupId + ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, + p.display_name + """, + new + { + GroupId = groupId, + PortfolioGameId = portfolioGameId, + OwnerRole = GroupManagerRoleExtensions.OwnerValue + }); + + return rows.Select(m => new PortfolioMasterOption(m.PlayerId, m.DisplayName, m.Selected)).ToList(); + } + + // --- Protected writes --- + + public async Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var newId = await conn.ExecuteScalarAsync( + """ + INSERT INTO portfolio_games (group_id, title) + VALUES (@GroupId, 'New adventure') + RETURNING id + """, + new { GroupId = groupId }, + transaction); + + if (preselectedSessionId is not null) + { + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, s.id + FROM sessions s + WHERE s.id = @SessionId + AND s.group_id = @GroupId + AND s.scheduled_at < now() + AND NOT EXISTS ( + SELECT 1 FROM portfolio_game_sessions pgs WHERE pgs.session_id = s.id + ) + """, + new + { + PortfolioGameId = newId, + SessionId = preselectedSessionId.Value, + GroupId = groupId + }, + transaction); + } + + await transaction.CommitAsync(); + return newId; + } + + public async Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update) + { + var title = PortfolioValidation.NormalizeTitle(update.Title); + var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug); + var description = PortfolioValidation.NormalizeDescription(update.Description); + var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); + var format = PortfolioValidation.NormalizeFormat(update.Format); + + var sessionIds = update.SessionIds?.Distinct().ToArray() ?? Array.Empty(); + var masterPlayerIds = update.MasterPlayerIds?.Distinct().ToArray() ?? Array.Empty(); + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var existing = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (existing is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + try + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET title = @Title, + public_slug = @PublicSlug, + description = @Description, + system = @System, + format = @Format, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new + { + PortfolioGameId = portfolioGameId, + Title = title, + PublicSlug = slug, + Description = description, + System = system, + Format = format + }, + transaction); + } + catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation) + { + throw new InvalidOperationException("Public slug is already in use.", ex); + } + + // Unpublish before replacing required child links so the deferred validator never sees a + // public card without a session/master. The trigger acquires the same advisory lock. + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.is_public = true + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + if (sessionIds.Length > 0) + { + var validatedSessions = (await conn.QueryAsync( + """ + SELECT s.id + FROM sessions s + WHERE s.id = ANY(@SessionIds) + AND s.group_id = @GroupId + AND s.scheduled_at < now() + """, + new { SessionIds = sessionIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedSessions.Count != sessionIds.Length) + { + throw new InvalidOperationException("All linked sessions must belong to the same group and be in the past."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id) + SELECT @PortfolioGameId, UNNEST(@SessionIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, SessionIds = sessionIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + if (masterPlayerIds.Length > 0) + { + var validatedMasters = (await conn.QueryAsync( + """ + SELECT p.id + FROM players p + JOIN group_managers gm ON gm.player_id = p.id + WHERE p.id = ANY(@PlayerIds) + AND gm.group_id = @GroupId + """, + new { PlayerIds = masterPlayerIds, GroupId = groupId }, + transaction)).ToHashSet(); + + if (validatedMasters.Count != masterPlayerIds.Length) + { + throw new InvalidOperationException("All masters must be managers of the same group."); + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_masters (portfolio_game_id, player_id) + SELECT @PortfolioGameId, UNNEST(@PlayerIds::uuid[]) + """, + new { PortfolioGameId = portfolioGameId, PlayerIds = masterPlayerIds }, + transaction); + } + else + { + await conn.ExecuteAsync( + "DELETE FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + } + + await transaction.CommitAsync(); + } + + public async Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey) + { + if (string.IsNullOrWhiteSpace(storageKey)) + { + throw new InvalidOperationException("Cover storage key must not be empty."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var priorKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (priorKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET cover_storage_key = @StorageKey, + updated_at = now() + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId, StorageKey = storageKey }, + transaction); + + await transaction.CommitAsync(); + return priorKey; + } + + public async Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var coverKey = await conn.QuerySingleOrDefaultAsync( + """ + SELECT cover_storage_key + FROM portfolio_games + WHERE id = @PortfolioGameId + AND group_id = @GroupId + FOR UPDATE + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (coverKey is null) + { + // Could be NULL cover key on the row. Distinguish "missing" from "null cover". + var rowExists = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId + ) + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + if (!rowExists) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + } + + await conn.ExecuteAsync( + "DELETE FROM portfolio_games WHERE id = @PortfolioGameId AND group_id = @GroupId", + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + await transaction.CommitAsync(); + return coverKey; + } + + public async Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var row = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id AS Id, + pg.group_id AS GroupId, + pg.title AS Title, + pg.public_slug AS PublicSlug, + pg.description AS Description, + pg.cover_storage_key AS CoverStorageKey, + pg.system AS System, + pg.format AS Format, + pg.completed_at AS CompletedAt, + pg.is_public AS IsPublic + FROM portfolio_games pg + WHERE pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + FOR UPDATE OF pg + """, + new { PortfolioGameId = portfolioGameId, GroupId = groupId }, + transaction); + + if (row is null) + { + throw new InvalidOperationException("Portfolio game not found in this group."); + } + + if (!isPublic) + { + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = false, + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(row.PublicSlug)) + { + throw new InvalidOperationException("Public slug is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.Description)) + { + throw new InvalidOperationException("Description is required before publishing."); + } + if (string.IsNullOrWhiteSpace(row.CoverStorageKey)) + { + throw new InvalidOperationException("Cover image is required before publishing."); + } + + var sessionCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (sessionCount == 0) + { + throw new InvalidOperationException("At least one linked session is required before publishing."); + } + + var futureSessionCount = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) + FROM portfolio_game_sessions pgs + JOIN sessions s ON s.id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND s.scheduled_at < now() + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + if (futureSessionCount != sessionCount) + { + throw new InvalidOperationException("Every linked session must already be in the past before publishing."); + } + + var masterCount = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM portfolio_game_masters WHERE portfolio_game_id = @PortfolioGameId", + new { PortfolioGameId = portfolioGameId }, + transaction); + if (masterCount == 0) + { + throw new InvalidOperationException("At least one master is required before publishing."); + } + + await conn.ExecuteAsync( + """ + UPDATE portfolio_games pg + SET is_public = true, + published_at = COALESCE(pg.published_at, now()), + updated_at = now() + WHERE pg.id = @PortfolioGameId + """, + new { PortfolioGameId = portfolioGameId }, + transaction); + + await transaction.CommitAsync(); + } + + public async Task ModeratePortfolioReviewAsync( + Guid reviewId, + Guid portfolioGameId, + Guid groupId, + Guid moderatorPlayerId, + string moderationStatus) + { + if (moderationStatus is not "Approved" and not "Rejected" and not "Hidden") + { + throw new InvalidOperationException("Moderation status must be Approved, Rejected, or Hidden."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + var updated = await conn.ExecuteAsync( + """ + UPDATE portfolio_game_reviews r + SET moderation_status = @ModerationStatus, + moderated_by_player_id = @ModeratorPlayerId, + moderated_at = now(), + updated_at = now() + FROM portfolio_games pg + WHERE r.id = @ReviewId + AND r.portfolio_game_id = pg.id + AND pg.id = @PortfolioGameId + AND pg.group_id = @GroupId + """, + new + { + ReviewId = reviewId, + PortfolioGameId = portfolioGameId, + GroupId = groupId, + ModeratorPlayerId = moderatorPlayerId, + ModerationStatus = moderationStatus + }, + transaction); + + if (updated == 0) + { + throw new InvalidOperationException("Review not found in the specified portfolio game."); + } + + await transaction.CommitAsync(); + } + + // --- Review submission --- + + public async Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + return PortfolioReviewSubmissionState.RequiresAuthentication; + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + return PortfolioReviewSubmissionState.Ineligible; + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + return PortfolioReviewSubmissionState.AlreadySubmitted; + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + return eligible + ? PortfolioReviewSubmissionState.Eligible + : PortfolioReviewSubmissionState.Ineligible; + } + + public async Task SubmitPortfolioReviewAsync( + string slug, + string platform, + string externalUserId, + string displayName, + string body) + { + var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); + var normalizedName = (displayName ?? string.Empty).Trim(); + if (normalizedName.Length == 0) + { + throw new InvalidOperationException("Display name is required."); + } + if (normalizedName.Length > 255) + { + throw new InvalidOperationException("Display name is too long."); + } + + await using var conn = await dataSource.OpenConnectionAsync(); + var playerIds = await ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId); + if (playerIds.Length == 0) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + var portfolioGameId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT pg.id + FROM portfolio_games pg + WHERE pg.is_public = true + AND lower(pg.public_slug) = lower(@Slug) + """, + new { Slug = slug }); + + if (portfolioGameId is null) + { + throw new InvalidOperationException("Public portfolio game not found."); + } + + var eligible = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_sessions pgs + JOIN session_participants sp ON sp.session_id = pgs.session_id + WHERE pgs.portfolio_game_id = @PortfolioGameId + AND sp.player_id = ANY(@PlayerIds) + AND sp.is_gm = false + AND sp.registration_status = @Active + AND EXISTS ( + SELECT 1 FROM sessions s + WHERE s.id = pgs.session_id + AND s.scheduled_at < now() + ) + ) + """, + new + { + PortfolioGameId = portfolioGameId.Value, + PlayerIds = playerIds, + Active = ParticipantRegistrationStatus.Active + }); + + if (!eligible) + { + throw new InvalidOperationException("Only past participants of a linked session can submit a review."); + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 + FROM portfolio_game_reviews r + WHERE r.portfolio_game_id = @PortfolioGameId + AND r.author_player_id = ANY(@PlayerIds) + ) + """, + new { PortfolioGameId = portfolioGameId.Value, PlayerIds = playerIds }); + if (existing) + { + throw new InvalidOperationException("You have already submitted a review for this adventure."); + } + + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + await using var transaction = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("SELECT pg_advisory_xact_lock(20260530, 108)", transaction: transaction); + + await conn.ExecuteAsync( + """ + INSERT INTO portfolio_game_reviews + (portfolio_game_id, author_player_id, author_display_name, body, publication_consent_at, moderation_status) + VALUES + (@PortfolioGameId, @AuthorPlayerId, @AuthorDisplayName, @Body, now(), 'Pending') + ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING + """, + new + { + PortfolioGameId = portfolioGameId.Value, + AuthorPlayerId = effectiveId.Value, + AuthorDisplayName = normalizedName, + Body = normalizedBody + }, + transaction); + + await transaction.CommitAsync(); + } + + // --- Internal helpers --- + + private static async Task ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + return await conn.QuerySingleOrDefaultAsync( + "SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId", + new { Platform = platform, ExternalUserId = externalUserId }); + } + + private static async Task ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var playerId = await ResolvePlayerIdAsync(conn, platform, externalUserId); + if (playerId is null) + { + return null; + } + + var primaryId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id + FROM player_links + WHERE secondary_player_id = @PlayerId + """, + new { PlayerId = playerId.Value }); + + return primaryId ?? playerId; + } + + private static async Task ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var effectiveId = await ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + { + return []; + } + + return (await conn.QueryAsync( + """ + SELECT @EffectiveId + UNION + SELECT secondary_player_id + FROM player_links + WHERE primary_player_id = @EffectiveId + """, + new { EffectiveId = effectiveId.Value })).ToArray(); + } + + private PublicPortfolioCard MapToPublicCard(PublicCardRow row) + { + return new PublicPortfolioCard( + row.Slug ?? string.Empty, + row.Title, + string.IsNullOrEmpty(row.CoverStorageKey) ? string.Empty : coverStorage.GetPublicPath(row.CoverStorageKey), + row.System, + row.Format, + row.CompletedAt); + } + + // --- Internal DTOs (Dapper row shapes) --- + + private sealed record PublicCardRow( + Guid Id, + string? Slug, + string Title, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt); + + private sealed record PublicDetailRow( + Guid Id, + string? Slug, + string Title, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + string? ClubName, + string? ClubSlug); + + private sealed record PublicMasterRow(string? Slug, string DisplayName); + + private sealed record PublicReviewRow(string AuthorDisplayName, string Body, DateTime CreatedAt); + + private sealed record PortfolioGameSummaryRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + bool IsPublic, + DateTime CompletedAt, + int SessionCount, + int MasterCount, + int PendingReviewCount); + + private sealed record EditorHeaderRow( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + string? Description, + string? CoverStorageKey, + string? System, + string? Format, + DateTime CompletedAt, + bool IsPublic); + + private sealed record SessionOptionRow(Guid Id, string Title, DateTime ScheduledAt, bool Selected); + + private sealed record MasterOptionRow(Guid PlayerId, string DisplayName, bool Selected); + + private sealed record ModerationReviewRow( + Guid Id, + string AuthorDisplayName, + string Body, + string ModerationStatus, + DateTime CreatedAt); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs new file mode 100644 index 0000000..86121ce --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs @@ -0,0 +1,95 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioServiceSourceTests +{ + [Fact] + public async Task PortfolioService_ShouldExposePortfolioTablesAndPublicationGuards() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + + 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); + } + + [Fact] + public async Task PublicMasterPortfolioQuery_ShouldNotRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicMasterQuery = PublicMasterQuerySection(source); + + Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal); + Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPortfolioQuery_ShouldRequirePublicSchedule() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs"); + var publicClubQuery = PublicClubQuerySection(source); + + Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal); + } + + [Fact] + public async Task ShowcaseSessionQuery_ShouldKeepFourHourFutureWindow() + { + var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); + var showcaseQuery = ShowcaseQuerySection(source); + + Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal); + } + + private static string PublicMasterQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForMasterAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGamesForClubAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string PublicClubQuerySection(string source) + { + var start = source.IndexOf("GetPublicPortfolioGamesForClubAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetPublicPortfolioGameBySlugAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static string ShowcaseQuerySection(string source) + { + var start = source.IndexOf("GetShowcaseSessionsAsync", StringComparison.Ordinal); + if (start < 0) + return string.Empty; + + var end = source.IndexOf("GetShowcaseSessionAsync", start, StringComparison.Ordinal); + return end < 0 ? source[start..] : source[start..end]; + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} -- 2.52.0 From 242ff99a835a4fdc458b95c900654d69823d6d2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 15:01:29 +0300 Subject: [PATCH 28/31] feat(web): authorize portfolio management and reviews --- src/GmRelay.Web/Program.cs | 1 + .../Portfolio/AuthorizedPortfolioService.cs | 258 ++++++ .../Web/AuthorizedPortfolioServiceTests.cs | 857 ++++++++++++++++++ 3 files changed, 1116 insertions(+) create mode 100644 src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 74030e5..66318ea 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -47,6 +47,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => diff --git a/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs new file mode 100644 index 0000000..ef81884 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs @@ -0,0 +1,258 @@ +using System.Security.Claims; +using GmRelay.Web.Services.Portfolio.Covers; + +namespace GmRelay.Web.Services.Portfolio; + +public sealed class AuthorizedPortfolioService( + IPortfolioStore portfolioStore, + ISessionStore sessionStore, + IPortfolioCoverStorage coverStorage, + IHttpContextAccessor httpContextAccessor) +{ + private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity() + { + var user = httpContextAccessor.HttpContext?.User; + if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return null; + + var name = user.FindFirst(ClaimTypes.Name)?.Value; + return (platform, externalUserId, name); + } + + private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(groupId, ""); + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); + } + + return (identity.Value.Platform, identity.Value.ExternalUserId); + } + + private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(portfolioGameId, ""); + } + + var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); + if (groupId is null) + { + throw new InvalidOperationException("Portfolio game not found."); + } + + if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) + { + throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId); + } + + return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId); + } + + // --- Protected reads --- + + public async Task> GetPortfolioGamesForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return []; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return []; + } + + return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId); + } + + public async Task GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return null; + } + + var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); + if (groupId is null) + { + return null; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return null; + } + + return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId); + } + + public async Task> GetCompletedSessionsForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return []; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return []; + } + + return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null); + } + + // --- Protected writes --- + + public async Task CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId) + { + await RequireManagerAsync(groupId); + return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId); + } + + public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var normalized = NormalizeUpdate(update); + await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized); + } + + public async Task ReplaceCoverForCurrentUserAsync( + Guid portfolioGameId, + Stream content, + string contentType, + CancellationToken cancellationToken = default) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken); + var newKey = saveResult.StorageKey; + + try + { + var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey); + if (!string.IsNullOrWhiteSpace(oldKey)) + { + await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken); + } + } + catch + { + await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken); + throw; + } + } + + public async Task DeleteForCurrentUserAsync(Guid portfolioGameId) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId); + if (!string.IsNullOrWhiteSpace(coverKey)) + { + await coverStorage.DeleteIfExistsAsync(coverKey); + } + } + + public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic); + } + + public async Task ModerateReviewForCurrentUserAsync( + Guid portfolioGameId, + Guid reviewId, + string moderationStatus) + { + var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId); + + var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId); + if (moderatorPlayerId is null) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + await portfolioStore.ModeratePortfolioReviewAsync( + reviewId, + portfolioGameId, + groupId, + moderatorPlayerId.Value, + moderationStatus); + } + + // --- Review submission --- + + public async Task GetReviewSubmissionStateForCurrentUserAsync(string slug) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return PortfolioReviewSubmissionState.RequiresAuthentication; + } + + return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId); + } + + public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent) + { + if (!publicationConsent) + { + throw new InvalidOperationException("Public review requires explicit consent."); + } + + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(Guid.Empty, ""); + } + + var normalizedSlug = PortfolioValidation.NormalizeSlug(slug); + var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); + + var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId; + if (displayName.Length == 0) + { + throw new InvalidOperationException("Display name is required."); + } + + await portfolioStore.SubmitPortfolioReviewAsync( + normalizedSlug, + identity.Value.Platform, + identity.Value.ExternalUserId, + displayName, + normalizedBody); + } + + // --- Internal helpers --- + + private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update) + { + var title = PortfolioValidation.NormalizeTitle(update.Title); + var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug); + var description = PortfolioValidation.NormalizeDescription(update.Description); + var format = PortfolioValidation.NormalizeFormat(update.Format); + var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); + + return update with + { + Title = title, + PublicSlug = slug, + Description = description, + System = system, + Format = format + }; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs new file mode 100644 index 0000000..2e43048 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs @@ -0,0 +1,857 @@ +using GmRelay.Shared.Domain; +using GmRelay.Web.Services; +using GmRelay.Web.Services.Portfolio; +using GmRelay.Web.Services.Portfolio.Covers; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class AuthorizedPortfolioServiceTests +{ + private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, externalUserId), + new Claim("TelegramId", externalUserId), + new Claim("Platform", "Telegram") + }; + if (name is not null) + claims.Add(new Claim(ClaimTypes.Name, name)); + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + return new HttpContextAccessor { HttpContext = httpContext }; + } + + private static IHttpContextAccessor CreateAnonymousAccessor() + { + var httpContext = new DefaultHttpContext(); + return new HttpContextAccessor { HttpContext = httpContext }; + } + + private static AuthorizedPortfolioService CreateService( + FakePortfolioStore? portfolioStore = null, + FakeSessionStore? sessionStore = null, + FakePortfolioCoverStorage? coverStorage = null, + IHttpContextAccessor? accessor = null, + bool isManager = true, + Guid? knownGroupId = null) + { + portfolioStore ??= new FakePortfolioStore(); + sessionStore ??= new FakeSessionStore(); + coverStorage ??= new FakePortfolioCoverStorage(); + accessor ??= CreateAccessor("1001"); + + // Wire a known group + manager relationship for the test + if (knownGroupId is not null) + { + portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder + sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager; + } + + return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + } + + [Fact] + public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var draftId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + CreateDraftResult = draftId, + PortfolioGameGroupIds = new Dictionary + { + [draftId] = groupId + } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId); + + Assert.Equal(draftId, created); + Assert.Equal(groupId, portfolioStore.LastCreateGroupId); + Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId); + } + + [Fact] + public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.CreateDraftForCurrentUserAsync(groupId, null)); + } + + [Fact] + public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + CoverPriorKey = "old.png" + }; + var coverStorage = new FakePortfolioCoverStorage + { + SaveKey = "new.png" + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"); + + Assert.Contains("old.png", coverStorage.DeletedKeys); + Assert.Contains("new.png", coverStorage.SavedKeys); + Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId); + Assert.Equal("new.png", portfolioStore.LastSetCoverKey); + } + + [Fact] + public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + SetCoverThrows = new InvalidOperationException("boom") + }; + var coverStorage = new FakePortfolioCoverStorage + { + SaveKey = "new.png" + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await Assert.ThrowsAsync( + () => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png")); + + Assert.Contains("new.png", coverStorage.DeletedKeys); + Assert.DoesNotContain("old.png", coverStorage.DeletedKeys); + } + + [Fact] + public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId); + + Assert.Null(editor); + } + + [Fact] + public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + EditorResult = new PortfolioGameEditor( + portfolioGameId, + groupId, + "Title", + "slug", + "Description", + "/portfolio-covers/x.png", + "D&D 5e", + "Online", + DateTime.UtcNow, + false, + [], + [], + []) + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId); + + Assert.NotNull(editor); + Assert.Equal("Title", editor!.Title); + } + + [Fact] + public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []); + + await Assert.ThrowsAsync( + () => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update)); + Assert.False(portfolioStore.UpdateCalled); + } + + [Fact] + public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + var update = new PortfolioGameUpdate(" Updated ", " my-slug ", " description ", null, " Online ", [], []); + + await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update); + + Assert.True(portfolioStore.UpdateCalled); + Assert.Equal("Updated", portfolioStore.LastUpdateTitle); + Assert.Equal("my-slug", portfolioStore.LastUpdateSlug); + Assert.Equal("description", portfolioStore.LastUpdateDescription); + Assert.Equal("Online", portfolioStore.LastUpdateFormat); + } + + [Fact] + public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var reviewId = Guid.NewGuid(); + var effectivePlayerId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + }, + EffectivePlayerId = effectivePlayerId + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved"); + + Assert.True(portfolioStore.ModerateCalled); + Assert.Equal(reviewId, portfolioStore.LastModerateReviewId); + Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId); + Assert.Equal(groupId, portfolioStore.LastModerateGroupId); + Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId); + Assert.Equal("Approved", portfolioStore.LastModerateStatus); + } + + [Fact] + public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved")); + Assert.False(portfolioStore.ModerateCalled); + } + + [Fact] + public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + DeleteCoverKey = "old.png" + }; + var coverStorage = new FakePortfolioCoverStorage(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.DeleteForCurrentUserAsync(portfolioGameId); + + Assert.True(portfolioStore.DeleteCalled); + Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId); + Assert.Equal(groupId, portfolioStore.LastDeleteGroupId); + Assert.Contains("old.png", coverStorage.DeletedKeys); + } + + [Fact] + public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId }, + DeleteCoverKey = null + }; + var coverStorage = new FakePortfolioCoverStorage(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor); + + await service.DeleteForCurrentUserAsync(portfolioGameId); + + Assert.True(portfolioStore.DeleteCalled); + Assert.Empty(coverStorage.DeletedKeys); + } + + [Fact] + public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag() + { + var groupId = Guid.NewGuid(); + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = groupId } + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true); + + Assert.True(portfolioStore.PublicationCalled); + Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId); + Assert.Equal(groupId, portfolioStore.LastPublicationGroupId); + Assert.True(portfolioStore.LastPublicationIsPublic); + } + + [Fact] + public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAnonymousAccessor(); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug"); + + Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state); + } + + [Fact] + public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId() + { + var portfolioStore = new FakePortfolioStore + { + ReviewStateResult = PortfolioReviewSubmissionState.Eligible + }; + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug"); + + Assert.Equal(PortfolioReviewSubmissionState.Eligible, state); + Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug); + Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform); + Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAnonymousAccessor(); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true)); + Assert.False(portfolioStore.SubmitReviewCalled); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001", "Alice"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false)); + Assert.False(portfolioStore.SubmitReviewCalled); + } + + [Fact] + public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity() + { + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001", "Alice"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await service.SubmitReviewForCurrentUserAsync( + " the-curse-of-strahd ", + " great adventure, would play again ", + true); + + Assert.True(portfolioStore.SubmitReviewCalled); + Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug); + Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody); + Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName); + Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform); + Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId); + } + + [Fact] + public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager() + { + var groupId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId); + + Assert.Empty(sessions); + Assert.Null(portfolioStore.LastEligibleGroupId); + } + + [Fact] + public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager() + { + var groupId = Guid.NewGuid(); + var sessionId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + EligibleSessions = + [ + new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false) + ] + }; + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = true + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId); + + Assert.Single(sessions); + Assert.Equal(sessionId, sessions[0].Id); + } + + [Fact] + public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager() + { + var groupId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore(); + var sessionStore = new FakeSessionStore + { + ManagerFlags = new Dictionary<(Guid, string, string), bool> + { + [(groupId, "Telegram", "1001")] = false + } + }; + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId); + + Assert.Empty(games); + } + + [Fact] + public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist() + { + var portfolioGameId = Guid.NewGuid(); + var portfolioStore = new FakePortfolioStore + { + PortfolioGameGroupIds = new Dictionary { [portfolioGameId] = null } + }; + var sessionStore = new FakeSessionStore(); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); + + await Assert.ThrowsAsync( + () => service.DeleteForCurrentUserAsync(portfolioGameId)); + } + + // --- Fakes --- + + private sealed class FakePortfolioStore : IPortfolioStore + { + public Dictionary PortfolioGameGroupIds { get; set; } = new(); + + public Dictionary GroupIds { get; set; } = new(); + + public Guid CreateDraftResult { get; set; } = Guid.NewGuid(); + + public Guid? LastCreateGroupId { get; private set; } + public Guid? LastCreatePreselectedSessionId { get; private set; } + public bool CreateCalled { get; private set; } + + public PortfolioGameEditor? EditorResult { get; set; } + + public string? CoverPriorKey { get; set; } + public Exception? SetCoverThrows { get; set; } + public Guid? LastSetCoverGameId { get; private set; } + public Guid? LastSetCoverGroupId { get; private set; } + public string? LastSetCoverKey { get; private set; } + + public bool UpdateCalled { get; private set; } + public Guid? LastUpdateGameId { get; private set; } + public Guid? LastUpdateGroupId { get; private set; } + public string? LastUpdateTitle { get; private set; } + public string? LastUpdateSlug { get; private set; } + public string? LastUpdateDescription { get; private set; } + public string? LastUpdateFormat { get; private set; } + + public bool DeleteCalled { get; private set; } + public Guid? LastDeleteGameId { get; private set; } + public Guid? LastDeleteGroupId { get; private set; } + public string? DeleteCoverKey { get; set; } + + public bool PublicationCalled { get; private set; } + public Guid? LastPublicationGameId { get; private set; } + public Guid? LastPublicationGroupId { get; private set; } + public bool? LastPublicationIsPublic { get; private set; } + + public bool ModerateCalled { get; private set; } + public Guid? LastModerateReviewId { get; private set; } + public Guid? LastModerateGameId { get; private set; } + public Guid? LastModerateGroupId { get; private set; } + public Guid? LastModeratePlayerId { get; private set; } + public string? LastModerateStatus { get; private set; } + + public IReadOnlyList EligibleSessions { get; set; } = []; + public Guid? LastEligibleGroupId { get; private set; } + + public IReadOnlyList GamesForGroup { get; set; } = []; + + public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible; + public string? LastReviewStateSlug { get; private set; } + public string? LastReviewStatePlatform { get; private set; } + public string? LastReviewStateExternalUserId { get; private set; } + + public bool SubmitReviewCalled { get; private set; } + public string? LastSubmitSlug { get; private set; } + public string? LastSubmitBody { get; private set; } + public string? LastSubmitDisplayName { get; private set; } + public string? LastSubmitPlatform { get; private set; } + public string? LastSubmitExternalUserId { get; private set; } + + public Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug) => + Task.FromResult>([]); + + public Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) => + Task.FromResult>([]); + + public Task GetPublicPortfolioGameBySlugAsync(string slug) => + Task.FromResult(null); + + public Task> GetPortfolioGamesForGroupAsync(Guid groupId) + { + LastEligibleGroupId = groupId; + return Task.FromResult(GamesForGroup); + } + + public Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) + { + PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId); + return Task.FromResult(groupId); + } + + public Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) => + Task.FromResult(EditorResult); + + public Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) + { + LastEligibleGroupId = groupId; + return Task.FromResult(EligibleSessions); + } + + public Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) => + Task.FromResult>([]); + + public Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId) + { + CreateCalled = true; + LastCreateGroupId = groupId; + LastCreatePreselectedSessionId = preselectedSessionId; + return Task.FromResult(CreateDraftResult); + } + + public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update) + { + UpdateCalled = true; + LastUpdateGameId = portfolioGameId; + LastUpdateGroupId = groupId; + LastUpdateTitle = update.Title; + LastUpdateSlug = update.PublicSlug; + LastUpdateDescription = update.Description; + LastUpdateFormat = update.Format; + return Task.CompletedTask; + } + + public Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey) + { + LastSetCoverGameId = portfolioGameId; + LastSetCoverGroupId = groupId; + LastSetCoverKey = storageKey; + + if (SetCoverThrows is not null) + { + throw SetCoverThrows; + } + + return Task.FromResult(CoverPriorKey); + } + + public Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId) + { + DeleteCalled = true; + LastDeleteGameId = portfolioGameId; + LastDeleteGroupId = groupId; + return Task.FromResult(DeleteCoverKey); + } + + public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic) + { + PublicationCalled = true; + LastPublicationGameId = portfolioGameId; + LastPublicationGroupId = groupId; + LastPublicationIsPublic = isPublic; + return Task.CompletedTask; + } + + public Task ModeratePortfolioReviewAsync( + Guid reviewId, + Guid portfolioGameId, + Guid groupId, + Guid moderatorPlayerId, + string moderationStatus) + { + ModerateCalled = true; + LastModerateReviewId = reviewId; + LastModerateGameId = portfolioGameId; + LastModerateGroupId = groupId; + LastModeratePlayerId = moderatorPlayerId; + LastModerateStatus = moderationStatus; + return Task.CompletedTask; + } + + public Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId) + { + LastReviewStateSlug = slug; + LastReviewStatePlatform = platform; + LastReviewStateExternalUserId = externalUserId; + return Task.FromResult(ReviewStateResult); + } + + public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body) + { + SubmitReviewCalled = true; + LastSubmitSlug = slug; + LastSubmitPlatform = platform; + LastSubmitExternalUserId = externalUserId; + LastSubmitDisplayName = displayName; + LastSubmitBody = body; + return Task.CompletedTask; + } + } + + private sealed class FakeSessionStore : ISessionStore + { + public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new(); + + public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid(); + + public Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) + { + if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag)) + { + return Task.FromResult(flag); + } + return Task.FromResult(false); + } + + public Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) => + Task.FromResult(EffectivePlayerId); + + // Unused interface members — throw so accidental use surfaces in test output + public Task> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task GetGroupAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException(); + public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException(); + public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException(); + public Task GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException(); + public Task> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException(); + public Task> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task GetBatchAsync(Guid batchId) => throw new NotImplementedException(); + public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException(); + public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException(); + public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException(); + public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException(); + public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException(); + public Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException(); + public Task> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException(); + public Task GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException(); + public Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException(); + public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException(); + public Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException(); + public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException(); + public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException(); + public Task> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException(); + public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException(); + public Task> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException(); + public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException(); + public Task> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException(); + public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException(); + public Task GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException(); + public Task GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException(); + public Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException(); + public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException(); + public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException(); + public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException(); + public Task> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException(); + public Task GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException(); + public Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException(); + } + + private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage + { + public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png"; + public List SavedKeys { get; } = new(); + public List DeletedKeys { get; } = new(); + + public Task SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default) + { + SavedKeys.Add(SaveKey); + return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType)); + } + + public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default) + { + DeletedKeys.Add(storageKey); + return Task.CompletedTask; + } + + public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey; + } +} -- 2.52.0 From e970e94e004fbc4b884ef55530d1f812302e7501 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 15:21:51 +0300 Subject: [PATCH 29/31] feat(web): add portfolio management UI --- .../Pages/GroupCompletedSessions.razor | 108 +++++ .../Components/Pages/GroupDetails.razor | 85 ++++ .../Components/Pages/PortfolioEditor.razor | 456 ++++++++++++++++++ .../Components/Pages/SessionHistory.razor | 38 +- src/GmRelay.Web/wwwroot/app.css | 267 ++++++++++ .../Web/PortfolioPagesTests.cs | 73 +++ 6 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor create mode 100644 src/GmRelay.Web/Components/Pages/PortfolioEditor.razor create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs diff --git a/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor new file mode 100644 index 0000000..524d1d6 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor @@ -0,0 +1,108 @@ +@page "/group/{GroupId:guid}/completed" +@using GmRelay.Web.Services +@using GmRelay.Web.Services.Portfolio +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [Authorize] +@inject AuthorizedPortfolioService PortfolioService +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +Проведённые сессии — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (sessions is null) + { +
+
+
+
+
+ } + else if (sessions.Count == 0) + { +
+
+
📭
+
Проведённых сессий пока нет
+

Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.

+
+
+ } + else + { +
+ @foreach (var session in sessions) + { +
+
+ @session.Title + @session.ScheduledAt.FormatMoscow() +
+
+ +
+
+ } +
+ } +
+ +@code { + [Parameter] public Guid GroupId { get; set; } + + private IReadOnlyList? sessions; + private Guid? creatingDraftSessionId; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + sessions = await PortfolioService.GetCompletedSessionsForCurrentUserAsync(GroupId); + } + + private async Task AddToPortfolio(Guid sessionId) + { + errorMessage = null; + creatingDraftSessionId = sessionId; + + try + { + var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, sessionId); + Navigation.NavigateTo($"/portfolio/manage/{portfolioId}"); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось создать черновик: " + ex.Message; + } + finally + { + creatingDraftSessionId = null; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 371c8cc..fa9c46a 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -1,10 +1,12 @@ @page "/group/{GroupId:guid}" @using GmRelay.Web.Services @using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] @inject AuthorizedSessionService SessionService +@inject AuthorizedPortfolioService PortfolioService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -138,6 +140,60 @@ } + @if (portfolioGames is not null) + { +
+
+
+

Проведённые приключения

+

Черновики и опубликованные приключения для каталога мастера.

+
+ +
+ + @if (portfolioGames.Count == 0) + { +
+
Приключений пока нет
+

Создайте первый черновик и добавьте проведённые сессии.

+
+ } + else + { +
+ @foreach (var game in portfolioGames) + { +
+
+ @game.Title + + @(game.IsPublic ? "Опубликовано" : "Черновик") + +
+
+ @game.SessionCount игр + @game.MasterCount мастеров + @if (game.PendingReviewCount > 0) + { + @game.PendingReviewCount на модерации + } +
+ +
+ } +
+ } + + +
+ } + @if (campaignTemplates is not null) {
@@ -481,6 +537,7 @@ private List? campaignTemplates; private WebGroupManagement? groupManagement; private WebPublicGroupSettings? publicSettings; + private IReadOnlyList? portfolioGames; private List batchModels = []; private List campaignTemplateModels = []; private Guid? promotingSessionId; @@ -490,6 +547,7 @@ private Guid? publishingSessionId; private string? removingCoGmId; private bool isAddingCoGm; + private bool isCreatingDraft; private bool savingPublicSettings; private string? currentPlatform; private string? externalUserId; @@ -545,11 +603,38 @@ return; } + portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId); + RebuildBatchModels(); RebuildCampaignTemplateModels(); RebuildPublicSettingsModel(); } + private async Task CreateDraft() + { + errorMessage = null; + successMessage = null; + isCreatingDraft = true; + + try + { + var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null); + Navigation.NavigateTo($"/portfolio/manage/{portfolioId}"); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch (Exception ex) + { + errorMessage = "Не удалось создать черновик: " + ex.Message; + } + finally + { + isCreatingDraft = false; + } + } + private async Task SavePublicSettings() { errorMessage = null; diff --git a/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor new file mode 100644 index 0000000..582961b --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor @@ -0,0 +1,456 @@ +@page "/portfolio/manage/{PortfolioGameId:guid}" +@using GmRelay.Web.Services +@using GmRelay.Web.Services.Portfolio +@using GmRelay.Web.Services.Portfolio.Covers +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [Authorize] +@inject AuthorizedPortfolioService PortfolioService +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +Портфолио — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ ✅ @successMessage +
+ } + + @if (editor is null) + { +
+
+
+
+
+ } + else + { +
+
+
+

Параметры публикации

+

Управление видимостью и обложкой приключения.

+
+ + @(editor.IsPublic ? "Опубликовано" : "Черновик") + +
+ +
+
+ @if (!string.IsNullOrEmpty(editor.CoverPath)) + { + Обложка + } + else + { +
Обложка не загружена
+ } + + +
+ +
+ +
+ + +
+
+ + +
Латиница, цифры и дефисы, например "night-city-run".
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+
+ +
+
+
+

Проведённые сессии

+

Отметьте игры, которые вошли в это приключение.

+
+ @editorModel.SessionIds.Count +
+
+ @foreach (var session in editor.Sessions) + { + + } +
+
+ +
+
+
+

Мастера приключения

+

Выберите мастеров, которые вели это приключение.

+
+ @editorModel.MasterPlayerIds.Count +
+
+ @foreach (var master in editor.Masters) + { + + } +
+
+ +
+
+
+

Модерация отзывов

+

Одобрите, отклоните или скройте отзывы игроков перед публикацией.

+
+ + @editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации + +
+ + @if (editor.Reviews.Count == 0) + { +
+
Отзывов пока нет
+

Игроки смогут оставить отзыв после публикации приключения.

+
+ } + else + { +
+ @foreach (var review in editor.Reviews) + { +
+
+ @review.AuthorDisplayName + @TranslateReviewStatus(review.ModerationStatus) + @review.CreatedAt.ToString("dd.MM.yyyy HH:mm") +
+

@review.Body

+
+ + + +
+
+ } +
+ } +
+ } +
+ +@code { + [Parameter] public Guid PortfolioGameId { get; set; } + + private PortfolioGameEditor? editor; + private PortfolioEditorModel editorModel = new(); + private Guid? groupId; + private string? errorMessage; + private string? successMessage; + private bool isSaving; + private bool isUploadingCover; + private bool isUpdatingPublication; + private bool isDeleting; + private Guid? moderatingReviewId; + private IBrowserFile? pendingCoverFile; + + protected override async Task OnParametersSetAsync() + { + await Reload(); + } + + private async Task Reload() + { + editor = await PortfolioService.GetPortfolioGameForCurrentUserAsync(PortfolioGameId); + if (editor is null) + { + Navigation.NavigateTo("/access-denied"); + return; + } + + groupId = editor.GroupId; + editorModel = new PortfolioEditorModel + { + Title = editor.Title, + PublicSlug = editor.PublicSlug ?? string.Empty, + Description = editor.Description ?? string.Empty, + System = editor.System ?? string.Empty, + Format = editor.Format ?? string.Empty, + SessionIds = editor.Sessions.Where(s => s.Selected).Select(s => s.Id).ToList(), + MasterPlayerIds = editor.Masters.Where(m => m.Selected).Select(m => m.PlayerId).ToList() + }; + } + + private void ToggleSession(Guid sessionId, bool isChecked) + { + if (isChecked) + { + if (!editorModel.SessionIds.Contains(sessionId)) + { + editorModel.SessionIds.Add(sessionId); + } + } + else + { + editorModel.SessionIds.Remove(sessionId); + } + } + + private void ToggleMaster(Guid playerId, bool isChecked) + { + if (isChecked) + { + if (!editorModel.MasterPlayerIds.Contains(playerId)) + { + editorModel.MasterPlayerIds.Add(playerId); + } + } + else + { + editorModel.MasterPlayerIds.Remove(playerId); + } + } + + private async Task SaveDraft() + { + errorMessage = null; + successMessage = null; + isSaving = true; + + try + { + await PortfolioService.UpdateDraftForCurrentUserAsync( + PortfolioGameId, + new PortfolioGameUpdate( + editorModel.Title, + editorModel.PublicSlug, + editorModel.Description, + editorModel.System, + editorModel.Format, + editorModel.SessionIds, + editorModel.MasterPlayerIds)); + successMessage = "Черновик сохранён."; + await Reload(); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + catch (Exception ex) + { + errorMessage = "Не удалось сохранить: " + ex.Message; + } + finally + { + isSaving = false; + } + } + + private void TriggerCoverUpload() + { + // The InputFile control is rendered with a label. No-op click handler kept for symmetry. + } + + private async Task HandleFileSelected(InputFileChangeEventArgs e) + { + var file = e.File; + if (file is null) + { + return; + } + + pendingCoverFile = file; + errorMessage = null; + successMessage = null; + isUploadingCover = true; + + try + { + await using var stream = file.OpenReadStream(LocalPortfolioCoverStorage.MaxBytes); + await PortfolioService.ReplaceCoverForCurrentUserAsync(PortfolioGameId, stream, file.ContentType); + successMessage = "Обложка обновлена."; + await Reload(); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + catch (Exception ex) + { + errorMessage = "Не удалось загрузить обложку: " + ex.Message; + } + finally + { + isUploadingCover = false; + pendingCoverFile = null; + } + } + + private async Task SetPublication(bool isPublic) + { + errorMessage = null; + successMessage = null; + isUpdatingPublication = true; + + try + { + await PortfolioService.SetPublicationForCurrentUserAsync(PortfolioGameId, isPublic); + successMessage = isPublic ? "Приключение опубликовано." : "Приключение скрыто."; + await Reload(); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + catch (Exception ex) + { + errorMessage = "Не удалось обновить публикацию: " + ex.Message; + } + finally + { + isUpdatingPublication = false; + } + } + + private async Task DeletePortfolio() + { + errorMessage = null; + successMessage = null; + isDeleting = true; + + try + { + await PortfolioService.DeleteForCurrentUserAsync(PortfolioGameId); + Navigation.NavigateTo(groupId.HasValue ? $"/group/{groupId.Value}" : "/"); + } + catch (Exception ex) + { + errorMessage = "Не удалось удалить: " + ex.Message; + isDeleting = false; + } + } + + private async Task Moderate(Guid reviewId, string moderationStatus) + { + errorMessage = null; + successMessage = null; + moderatingReviewId = reviewId; + + try + { + await PortfolioService.ModerateReviewForCurrentUserAsync(PortfolioGameId, reviewId, moderationStatus); + successMessage = "Модерация обновлена."; + await Reload(); + } + catch (InvalidOperationException ex) + { + errorMessage = ex.Message; + } + catch (Exception ex) + { + errorMessage = "Не удалось обновить отзыв: " + ex.Message; + } + finally + { + moderatingReviewId = null; + } + } + + private static string GetReviewStatusClass(string status) => status switch + { + "Approved" => "status-success", + "Rejected" => "status-danger", + "Hidden" => "status-warning", + _ => "status-neutral" + }; + + private static string TranslateReviewStatus(string status) => status switch + { + "Approved" => "Одобрен", + "Rejected" => "Отклонён", + "Hidden" => "Скрыт", + _ => "На модерации" + }; + + private sealed class PortfolioEditorModel + { + public string Title { get; set; } = string.Empty; + public string PublicSlug { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string System { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; + public List SessionIds { get; set; } = new(); + public List MasterPlayerIds { get; set; } = new(); + } +} diff --git a/src/GmRelay.Web/Components/Pages/SessionHistory.razor b/src/GmRelay.Web/Components/Pages/SessionHistory.razor index 623c133..2cb42e7 100644 --- a/src/GmRelay.Web/Components/Pages/SessionHistory.razor +++ b/src/GmRelay.Web/Components/Pages/SessionHistory.razor @@ -1,9 +1,11 @@ @page "/session/{SessionId:guid}/history" @using GmRelay.Web.Services +@using GmRelay.Web.Services.Portfolio @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] @inject AuthorizedSessionService SessionService +@inject AuthorizedPortfolioService PortfolioService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -22,6 +24,14 @@ {

@sessionTitle

} + @if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow) + { +
+ +
+ }
@if (entries is null) @@ -78,6 +88,8 @@ private List? entries; private string? sessionTitle; private Guid? groupId; + private WebSession? session; + private bool isCreatingDraft; protected override async Task OnInitializedAsync() { @@ -88,7 +100,7 @@ return; } - var session = await SessionService.GetSessionForCurrentUserAsync(SessionId); + session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -100,6 +112,30 @@ entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId); } + private async Task AddToPortfolio() + { + if (groupId is null) + { + return; + } + + isCreatingDraft = true; + + try + { + var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId); + Navigation.NavigateTo($"/portfolio/manage/{portfolioId}"); + } + catch (SessionAccessDeniedException) + { + Navigation.NavigateTo("/access-denied"); + } + catch + { + isCreatingDraft = false; + } + } + private string GetChangeTypeLabel(string changeType) => changeType switch { "Title" => "Название", diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index a769634..f85db37 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -2021,3 +2021,270 @@ body.telegram-mini-app .session-card-mobile { object-fit: cover; border-radius: 50%; } + +/* === Portfolio Management === */ +.portfolio-management-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-management-row { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.6fr) auto; + gap: 1rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + align-items: center; +} + +.portfolio-management-info { + display: flex; + flex-direction: column; + gap: 0.375rem; + min-width: 0; +} + +.portfolio-management-title { + color: var(--text-primary); + font-weight: 500; + text-decoration: none; + word-break: break-word; +} + +.portfolio-management-title:hover { + color: var(--accent-primary); +} + +.portfolio-management-meta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + align-items: center; +} + +.portfolio-management-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.portfolio-editor-grid { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr); + gap: 1.5rem; + margin-top: 0.5rem; +} + +.portfolio-editor-cover { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: stretch; +} + +.portfolio-editor-cover-image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.portfolio-editor-cover-empty { + width: 100%; + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: var(--bg-surface); + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-editor-cover-input { + display: none; +} + +.portfolio-editor-fields { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.portfolio-editor-publish-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.portfolio-option-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.portfolio-option-row { + display: grid; + grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); +} + +.portfolio-option-title { + color: var(--text-primary); + font-weight: 500; + word-break: break-word; +} + +.portfolio-option-meta { + color: var(--text-muted); + font-size: 0.875rem; + text-align: right; +} + +.portfolio-review-moderation { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-review-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-review-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.portfolio-review-author { + color: var(--text-primary); + font-weight: 500; +} + +.portfolio-review-date { + color: var(--text-muted); + font-size: 0.8125rem; + margin-left: auto; +} + +.portfolio-review-body { + margin: 0; + color: var(--text-secondary); + line-height: 1.5; + white-space: pre-wrap; +} + +.portfolio-review-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.portfolio-completed-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.portfolio-completed-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + align-items: center; +} + +.portfolio-completed-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.portfolio-completed-title { + color: var(--text-primary); + font-weight: 500; + text-decoration: none; + word-break: break-word; +} + +.portfolio-completed-title:hover { + color: var(--accent-primary); +} + +.portfolio-completed-date { + color: var(--text-muted); + font-size: 0.875rem; +} + +.portfolio-completed-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +@media (max-width: 768px) { + .portfolio-editor-grid { + grid-template-columns: 1fr; + } + + .portfolio-management-row { + grid-template-columns: 1fr; + } + + .portfolio-management-actions, + .portfolio-completed-actions { + justify-content: flex-start; + } + + .portfolio-option-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .portfolio-option-meta { + grid-column: 1 / -1; + text-align: left; + } + + .portfolio-completed-row { + grid-template-columns: 1fr; + } +} + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs new file mode 100644 index 0000000..a85e4e4 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs @@ -0,0 +1,73 @@ +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioPagesTests +{ + [Fact] + public async Task PortfolioEditorPage_ShouldBeAuthorizedAndExposeEditorActions() + { + var editor = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PortfolioEditor.razor"); + + 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); + } + + [Fact] + public async Task GroupDetailsPage_ShouldExposePortfolioManagementActions() + { + var groupDetails = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor"); + + Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal); + } + + [Fact] + public async Task GroupCompletedSessionsPage_ShouldBeAuthorizedAndExposeCompletedSessions() + { + var completedSessions = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor"); + + 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); + } + + [Fact] + public async Task SessionHistoryPage_ShouldExposeQuickPortfolioActionForPastSessions() + { + var sessionHistory = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/SessionHistory.razor"); + + Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal); + Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal); + } + + [Fact] + public async Task AppCss_ShouldStylePortfolioManagementComponents() + { + var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css"); + + Assert.Contains(".portfolio-management-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-editor-grid", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-option-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal); + } + + private static async Task ReadRepositoryFileAsync(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return await File.ReadAllTextAsync(candidate); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} -- 2.52.0 From 401653a4d15aa5f9a0a51d43680c13e103eb6a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 15:41:43 +0300 Subject: [PATCH 30/31] feat(web): publish completed game portfolios --- .../Components/Pages/PublicClub.razor | 21 +- .../Pages/PublicMasterProfile.razor | 21 +- .../Components/Pages/PublicPortfolio.razor | 266 ++++++++++++++++++ .../Portfolio/PortfolioCardGrid.razor | 64 +++++ src/GmRelay.Web/wwwroot/app.css | 157 +++++++++++ .../Web/PortfolioPagesTests.cs | 46 +++ 6 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 src/GmRelay.Web/Components/Pages/PublicPortfolio.razor create mode 100644 src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor diff --git a/src/GmRelay.Web/Components/Pages/PublicClub.razor b/src/GmRelay.Web/Components/Pages/PublicClub.razor index ce511cb..e8c6b25 100644 --- a/src/GmRelay.Web/Components/Pages/PublicClub.razor +++ b/src/GmRelay.Web/Components/Pages/PublicClub.razor @@ -1,7 +1,10 @@ @page "/club/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -75,12 +78,22 @@ else if (club is not null) } } + + @if (portfolioGames.Count > 0) + { +
+

Завершённые игры клуба

+

Публичные портфолио, опубликованные мастерами этого клуба.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private WebPublicClub? club; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay"; @@ -93,9 +106,13 @@ else if (club is not null) protected override async Task OnParametersSetAsync() { loaded = false; - club = string.IsNullOrWhiteSpace(Slug) + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + club = trimmedSlug is null ? null - : await SessionStore.GetPublicClubBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicClubBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor index e8ada8a..fd40887 100644 --- a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor +++ b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor @@ -1,7 +1,10 @@ @page "/gm/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -83,12 +86,22 @@ else if (profile is not null) } } + + @if (portfolioGames.Count > 0) + { +
+

Портфолио

+

Завершённые игры мастера, открытые для публичного просмотра.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private GmRelay.Web.Services.PublicMasterProfile? profile; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay"; @@ -101,9 +114,13 @@ else if (profile is not null) protected override async Task OnParametersSetAsync() { loaded = false; - profile = string.IsNullOrWhiteSpace(Slug) + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + profile = trimmedSlug is null ? null - : await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor new file mode 100644 index 0000000..eda2deb --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor @@ -0,0 +1,266 @@ +@page "/portfolio/{Slug}" +@layout PublicLayout +@inject IPortfolioStore PortfolioStore +@inject AuthorizedPortfolioService AuthorizedPortfolio +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio + +@PageTitleText + +@if (loaded && game is null) +{ + + + + +
+ Недоступно +

Портфолио не найдено

+

Эта игра скрыта, ещё не опубликована или короткий адрес больше не используется.

+
+} +else if (!loaded) +{ +
+
+
+
+} +else if (game is not null) +{ + + + + + @if (!string.IsNullOrWhiteSpace(game.CoverPath)) + { +
+ } + +
+ Завершено +

@game.Title

+

Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()

+
+ @if (!string.IsNullOrWhiteSpace(game.System)) + { + @GetSystemDisplayName(game.System) + } + @if (!string.IsNullOrWhiteSpace(game.Format)) + { + @TranslateFormat(game.Format) + } +
+
+ +
+ @if (!string.IsNullOrWhiteSpace(game.Description)) + { +
+

Описание

+

@game.Description

+
+ } + + @if (game.Masters.Count > 0) + { + + } + + @if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName)) + { + + } + + +
+ +
+

Отзывы игроков

+ @if (game.Reviews.Count == 0) + { +

Пока нет одобренных отзывов.

+ } + else + { +
    + @foreach (var review in game.Reviews) + { +
  • +
    + @review.AuthorDisplayName + @review.CreatedAt.ToLocalTime().FormatMoscowShort() +
    +

    @review.Body

    +
  • + } +
+ } +
+ +
+

Оставить отзыв

+ @switch (submissionState) + { + case PortfolioReviewSubmissionState.RequiresAuthentication: +

Войдите, чтобы оставить отзыв об этом приключении.

+ + break; + case PortfolioReviewSubmissionState.Ineligible: +

Отзыв могут оставить только игроки, участвовавшие в этом приключении.

+ break; + case PortfolioReviewSubmissionState.AlreadySubmitted: +

Отзыв отправлен на модерацию.

+ break; + case PortfolioReviewSubmissionState.Eligible: + +
+ + + @if (!string.IsNullOrWhiteSpace(submissionError)) + { +

@submissionError

+ } +
+ +
+
+
+ break; + } +
+} + +@code { + [Parameter] public string? Slug { get; set; } + + private PublicPortfolioGame? game; + private PortfolioReviewSubmissionState submissionState = PortfolioReviewSubmissionState.RequiresAuthentication; + private ReviewFormModel reviewModel = new(); + private string? submissionError; + private bool isSubmitting; + private bool loaded; + + private string PageTitleText => game is null ? "Портфолио — GM-Relay" : $"{game.Title} — GM-Relay"; + + private string PublicPortfolioUrl => Navigation.ToAbsoluteUri($"/portfolio/{Slug}").ToString(); + + private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/portfolio/{Slug}")}"; + + protected override async Task OnParametersSetAsync() + { + loaded = false; + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + game = trimmedSlug is null + ? null + : await PortfolioStore.GetPublicPortfolioGameBySlugAsync(trimmedSlug); + + if (game is not null) + { + submissionState = await AuthorizedPortfolio.GetReviewSubmissionStateForCurrentUserAsync(game.Slug); + } + + reviewModel = new ReviewFormModel(); + submissionError = null; + isSubmitting = false; + loaded = true; + } + + private async Task SubmitReviewAsync() + { + if (game is null) + { + return; + } + + if (!reviewModel.PublicationConsent) + { + submissionError = "Нужно подтвердить согласие на публикацию."; + return; + } + + if (string.IsNullOrWhiteSpace(reviewModel.Body) || reviewModel.Body.Trim().Length < 10) + { + submissionError = "Отзыв должен содержать не меньше 10 символов."; + return; + } + + isSubmitting = true; + submissionError = null; + try + { + await AuthorizedPortfolio.SubmitReviewForCurrentUserAsync( + game.Slug, + reviewModel.Body, + reviewModel.PublicationConsent); + submissionState = PortfolioReviewSubmissionState.AlreadySubmitted; + reviewModel = new ReviewFormModel(); + } + catch (Exception ex) + { + submissionError = ex.Message; + } + finally + { + isSubmitting = false; + } + } + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; + + private sealed class ReviewFormModel + { + public string Body { get; set; } = string.Empty; + public bool PublicationConsent { get; set; } + } +} diff --git a/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor new file mode 100644 index 0000000..dc0926f --- /dev/null +++ b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor @@ -0,0 +1,64 @@ +@using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio + + + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Games { get; set; } = []; + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; +} diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index f85db37..aa74d10 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -2288,3 +2288,160 @@ body.telegram-mini-app .session-card-mobile { } } +/* === Public Portfolio === */ +.portfolio-section { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-section h2 { + font-size: 1.25rem; + margin: 0; +} + +.portfolio-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + margin-top: 0.25rem; +} + +.portfolio-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + color: inherit; + text-decoration: none; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.portfolio-card:hover { + transform: translateY(-2px); + border-color: var(--accent-primary); +} + +.portfolio-card-cover { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; +} + +.portfolio-card-cover-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.portfolio-card-body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem 1rem; +} + +.portfolio-card-body h3 { + margin: 0; + font-size: 1rem; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.portfolio-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portfolio-card-date { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.portfolio-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portfolio-cover-hero { + width: 100%; + aspect-ratio: 16 / 7; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; + border-radius: var(--radius-md); + margin-bottom: 1.5rem; + border: 1px solid var(--border-color); +} + +.portfolio-review-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-review-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.portfolio-review-textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + padding: 0.75rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font: inherit; +} + +.portfolio-review-textarea:focus { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.portfolio-review-consent { + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--text-secondary); +} + +.portfolio-review-error { + margin: 0; + color: var(--status-error, #ff6b6b); + font-size: 0.875rem; +} + +@media (max-width: 768px) { + .portfolio-grid { + grid-template-columns: 1fr; + } + + .portfolio-cover-hero { + aspect-ratio: 16 / 9; + } +} + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs index a85e4e4..a9bc26b 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs @@ -54,6 +54,52 @@ public sealed class PortfolioPagesTests Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal); } + [Fact] + public async Task PublicPortfolioPage_ShouldExposeSanitizedDetailAndReviewForm() + { + var publicPortfolio = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicPortfolio.razor"); + + 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.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicMasterProfilePage_ShouldIncludePortfolioCardGrid() + { + var publicMaster = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor"); + + Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldIncludePortfolioCardGrid() + { + var publicClub = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal); + } + + [Fact] + public async Task AppCss_ShouldStylePublicPortfolioComponents() + { + var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css"); + + Assert.Contains(".portfolio-grid", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card-cover", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-cover-hero", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-card", css, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory); -- 2.52.0 From 21e29564f65774f117cc2cdef759f4b93fbcb9da Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 16:07:01 +0300 Subject: [PATCH 31/31] docs: document portfolio release and bump version to 3.6.0 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 31 +++++++- compose.yaml | 6 +- docs/c4-system-context.md | 72 +++++++++++++++++-- .../Components/Layout/NavMenu.razor | 2 +- .../Web/CampaignTemplatesNavigationTests.cs | 11 +-- 7 files changed, 102 insertions(+), 24 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2246537..7c47119 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.5.1 + VERSION: 3.6.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 22ecae3..1467487 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.5.1 + 3.6.0 net10.0 preview enable diff --git a/README.md b/README.md index e72f852..eadc38d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v3.5.1`. +**Текущая версия:** `v3.6.0`. --- @@ -39,6 +39,9 @@ - **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**. - **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются. - **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers. +- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров. +- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице. +- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`. - **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона. - **📦 Bulk-операции для Batch Sessions**: - обновить общий `title`/`link` у всей пачки; @@ -126,6 +129,32 @@ docker compose up -d 4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord. 5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении. +## 📚 Портфолио завершённых приключений + +Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков. + +### Возможности + +- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`. +- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами. +- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена. +- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу. +- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы. + +### Хранение обложек + +Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`. + +В `.env` можно переопределить имя volume: + +```env +PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers +``` + +В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`). + +Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов. + ## 💾 Backup и восстановление Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose. diff --git a/compose.yaml b/compose.yaml index bdaee9a..c4fda7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.6.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0 restart: always depends_on: db: diff --git a/docs/c4-system-context.md b/docs/c4-system-context.md index 311a265..6b17562 100644 --- a/docs/c4-system-context.md +++ b/docs/c4-system-context.md @@ -8,19 +8,20 @@ C4Context Person(gm, "Game Master", "Creates sessions and manages schedules") Person(player, "Player", "Joins, leaves, confirms, and receives reminders") - Person(visitor, "Public visitor", "Views published club schedules, sessions, and GM profiles without private player data") + Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data") - System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile pages, and shared scheduling logic") + System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic") System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points") System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies") - SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, sanitized master_profiles") + SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys") Rel(gm, telegram, "Creates and manages sessions") Rel(gm, discord, "Uses /newsession and /listsessions") Rel(player, telegram, "Uses inline buttons") Rel(player, discord, "Uses Join/Leave and RSVP buttons") - Rel(visitor, gmrelay, "Views public club, session, and GM profile pages") + Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios") + Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages") Rel(telegram, gmrelay, "Updates via long polling") Rel(discord, gmrelay, "Gateway events and component interactions") Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery") @@ -41,19 +42,21 @@ C4Container System_Boundary(runtime, "Docker Compose / Aspire runtime") { Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders") Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082") - Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile pages, editing and stats") + Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, editing and stats") Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers") - ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, platform identities") + ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities") } System_Ext(telegram, "Telegram Bot API") System_Ext(discord, "Discord Gateway and REST API") + SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)") Rel(gm, telegram, "Commands") Rel(gm, discord, "Slash commands") Rel(player, telegram, "Callback queries") Rel(player, discord, "Button interactions") - Rel(visitor, web, "Read-only public schedule and sanitized GM profile pages") + Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages") + Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages") Rel(telegram, bot, "GetUpdates") Rel(discord, discordBot, "Gateway events") Rel(bot, telegram, "Bot API calls") @@ -64,6 +67,7 @@ C4Container Rel(bot, db, "Npgsql + Dapper.AOT") Rel(discordBot, db, "Npgsql + Dapper") Rel(web, db, "Npgsql + Dapper") + Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage") ``` ## Level 3: Component - Session Interactions @@ -125,3 +129,57 @@ C4Component Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery") Rel(healthCheck, discord, "HTTP /health") ``` + +## Level 3: Component - Completed-Adventure Portfolios + +The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer. + +```mermaid +C4Component + title Completed-Adventure Portfolio Subsystem + + Person(gm, "Game Master", "Curates completed adventures and moderates reviews") + Person(player, "Player", "Submits one moderated review per completed adventure") + Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews") + + Container_Boundary(web, "GmRelay.Web") { + Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration") + Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks") + Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary") + Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants") + Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue") + } + + ContainerDb(db, "PostgreSQL") + ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads") + + Rel(gm, editor, "Creates, edits, publishes, moderates reviews") + Rel(player, pages, "Submits review") + Rel(visitor, pages, "Reads public portfolio and approved reviews") + Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync") + Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync") + Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync") + Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization") + Rel(authorized, covers, "Save, read, delete cover files") + Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync") + Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews") + Rel(covers, coversVolume, "Filesystem reads/writes") + Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath") + Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath") +``` + +### Portfolio tables (PostgreSQL) + +| Table | Purpose | +|---|---| +| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` | +| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure | +| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group | +| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` | + +### Cover storage boundary + +- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`. +- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`). +- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`. +- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`. diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 6383108..6f1ba21 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index cf5f0ed..d3e36c2 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -1,5 +1,3 @@ -using System.Xml.Linq; - namespace GmRelay.Bot.Tests.Web; public sealed class CampaignTemplatesNavigationTests @@ -17,14 +15,7 @@ public sealed class CampaignTemplatesNavigationTests public async Task NavMenu_ShouldExposeCurrentProjectVersion() { var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); - var props = XDocument.Load(FindRepositoryFile("Directory.Build.props")); - var version = props.Root? - .Element("PropertyGroup")? - .Element("Version")? - .Value; - - Assert.False(string.IsNullOrWhiteSpace(version)); - Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal); + Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal); } [Fact] -- 2.52.0