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

22 KiB

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

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.


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:

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

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

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

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:

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'.

Cover Storage

Contract

Add a small storage abstraction:

public interface IPortfolioCoverStorage
{
    Task<PortfolioCoverUploadResult> 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 IPortfolioStore. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.

Representative contracts:

public sealed record PublicPortfolioGame(
    string Slug,
    string Title,
    string Description,
    string CoverPath,
    string? System,
    string? Format,
    DateTime CompletedAt,
    string? ClubName,
    string? ClubSlug,
    IReadOnlyList<PublicPortfolioMaster> Masters,
    IReadOnlyList<PublicPortfolioReview> Reviews);

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

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

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, edit links, and a link to the club's completed-session list.

Completed Session Quick Action

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

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:

services:
  discord:
    depends_on:
      bot:
        condition: service_healthy

  web:
    depends_on:
      bot:
        condition: service_healthy
    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.

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


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