From 991c7e1965085499029ea7a8a6e043c286c37948 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Sat, 30 May 2026 14:16:12 +0300 Subject: [PATCH] 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.