Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6951c72f3c | |||
| 22e9859fdf | |||
| 6cb2fbe610 | |||
| 992f71c0e4 | |||
| 21e29564f6 | |||
| 401653a4d1 | |||
| e970e94e00 | |||
| 242ff99a83 | |||
| f2c9f34ab4 | |||
| e5945288ac | |||
| 7d1489445e | |||
| 4af4e52778 | |||
| a20da4b1a0 | |||
| edf40c9a09 | |||
| 1a8161027c | |||
| 85918c1e5d | |||
| ea714480d3 | |||
| 1d62f69ff0 | |||
| d762ecc377 | |||
| a28b75dd5b | |||
| 2b725708ef | |||
| da0a306340 | |||
| f493836b77 | |||
| 6e7a0cb493 | |||
| 76b3ff7ddf | |||
| 536061f63c | |||
| f7a12d14d2 | |||
| 3c1a98bcc4 | |||
| d591e5ed5a | |||
| 5809a470b9 | |||
| ed842d2195 | |||
| a0040ec9fb | |||
| 67b8aafd97 | |||
| ac417731d6 | |||
| 991c7e1965 | |||
| 0d9df29f58 |
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Имя Docker volume для резервных копий БД
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
|
||||
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.5.1
|
||||
VERSION: 3.7.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.5.1</Version>
|
||||
<Version>3.7.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -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.
|
||||
|
||||
+11
-3
@@ -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.7.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,11 +67,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.5.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.0
|
||||
restart: always
|
||||
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}"
|
||||
@@ -84,11 +86,13 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.5.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.0
|
||||
restart: always
|
||||
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}"
|
||||
@@ -97,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:
|
||||
@@ -116,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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
# 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:
|
||||
|
||||
```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 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.
|
||||
|
||||
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 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`
|
||||
|
||||
| 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:
|
||||
|
||||
```sql
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```yaml
|
||||
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, 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.
|
||||
|
||||
### 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.
|
||||
@@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithPgAdmin()
|
||||
.AddDatabase("gmrelay-db");
|
||||
.AddDatabase("gmrelaydb");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
|
||||
.WithHttpHealthCheck("/health", endpointName: "health");
|
||||
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WaitFor(bot);
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
-- 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 FUNCTION lock_portfolio_publication_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
|
||||
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
|
||||
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON game_groups
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
|
||||
BEFORE DELETE ON players
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||
|
||||
CREATE FUNCTION validate_public_portfolio_game_required_links()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
target_portfolio_game_id UUID;
|
||||
target_portfolio_game_ids UUID[];
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
IF TG_TABLE_NAME = 'portfolio_games' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.id];
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
|
||||
ELSE
|
||||
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
|
||||
END IF;
|
||||
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio publication validation requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
SELECT pg.id
|
||||
INTO target_portfolio_game_id
|
||||
FROM portfolio_games pg
|
||||
WHERE pg.id = ANY(target_portfolio_game_ids)
|
||||
AND pg.is_public = true
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_masters pgm
|
||||
WHERE pgm.portfolio_game_id = pg.id
|
||||
)
|
||||
)
|
||||
LIMIT 1;
|
||||
|
||||
IF target_portfolio_game_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'published portfolio game % must have at least one linked session and at least one linked master',
|
||||
target_portfolio_game_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
final_scheduled_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
SELECT s.scheduled_at
|
||||
INTO final_scheduled_at
|
||||
FROM sessions s
|
||||
WHERE s.id = NEW.id;
|
||||
|
||||
IF final_scheduled_at >= now() THEN
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION
|
||||
'portfolio future reschedule requires read committed isolation'
|
||||
USING ERRCODE = '0A000';
|
||||
END IF;
|
||||
|
||||
PERFORM pg.id
|
||||
FROM portfolio_games pg
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
)
|
||||
ORDER BY pg.id
|
||||
FOR UPDATE OF pg;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||
|
||||
UPDATE portfolio_games pg
|
||||
SET is_public = false,
|
||||
updated_at = now()
|
||||
WHERE pg.is_public = true
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_game_sessions pgs
|
||||
JOIN sessions s ON s.id = pgs.session_id
|
||||
WHERE pgs.portfolio_game_id = pg.id
|
||||
AND s.scheduled_at >= now()
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
|
||||
AFTER UPDATE OF scheduled_at ON sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
|
||||
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
|
||||
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
|
||||
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||
|
||||
CREATE TABLE portfolio_game_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
author_display_name VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
publication_consent_at TIMESTAMPTZ NOT NULL,
|
||||
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
|
||||
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
moderated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (portfolio_game_id, author_player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_author
|
||||
ON portfolio_game_reviews (author_player_id);
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_moderator
|
||||
ON portfolio_game_reviews (moderated_by_player_id)
|
||||
WHERE moderated_by_player_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_public
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_portfolio_game_reviews_pending
|
||||
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||
WHERE moderation_status = 'Pending';
|
||||
@@ -0,0 +1,66 @@
|
||||
-- V030: Private club showcases. Adds club_memberships (member access control)
|
||||
-- and replaces sessions.is_public with a 4-state publication_mode enum.
|
||||
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
|
||||
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
|
||||
|
||||
-- 1. club_memberships
|
||||
CREATE TABLE club_memberships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'Member'
|
||||
CHECK (role IN ('Member')),
|
||||
message TEXT,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
decided_at TIMESTAMPTZ,
|
||||
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Only one Active row per (group, player).
|
||||
-- Re-application after Rejected/Left creates a new row.
|
||||
CREATE UNIQUE INDEX ux_club_memberships_one_active
|
||||
ON club_memberships (group_id, player_id)
|
||||
WHERE status = 'Active';
|
||||
|
||||
CREATE INDEX ix_club_memberships_group_status
|
||||
ON club_memberships (group_id, status);
|
||||
|
||||
CREATE INDEX ix_club_memberships_player_status
|
||||
ON club_memberships (player_id, status);
|
||||
|
||||
-- 2. sessions.publication_mode (replaces is_public)
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
|
||||
|
||||
-- Backfill before constraint so existing data maps cleanly.
|
||||
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
|
||||
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD CONSTRAINT ck_sessions_publication_mode
|
||||
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||
|
||||
ALTER TABLE sessions DROP COLUMN is_public;
|
||||
|
||||
DROP INDEX IF EXISTS ix_sessions_public_schedule;
|
||||
DROP INDEX IF EXISTS ix_sessions_showcase;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||
|
||||
-- 3. portfolio_games.publication_mode
|
||||
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
|
||||
ALTER TABLE portfolio_games
|
||||
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
|
||||
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||
|
||||
CREATE INDEX ix_portfolio_games_showcase
|
||||
ON portfolio_games (created_at DESC)
|
||||
WHERE publication_mode IN ('Catalog', 'Both');
|
||||
@@ -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))
|
||||
@@ -43,6 +45,39 @@ 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<Guid?>(
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace GmRelay.Shared.Domain;
|
||||
|
||||
public enum PublicationMode
|
||||
{
|
||||
None,
|
||||
Catalog,
|
||||
ClubOnly,
|
||||
Both
|
||||
}
|
||||
|
||||
public static class PublicationModeExtensions
|
||||
{
|
||||
public const string NoneValue = nameof(PublicationMode.None);
|
||||
public const string CatalogValue = nameof(PublicationMode.Catalog);
|
||||
public const string ClubOnlyValue = nameof(PublicationMode.ClubOnly);
|
||||
public const string BothValue = nameof(PublicationMode.Both);
|
||||
|
||||
public static bool IsVisibleInCatalog(this PublicationMode mode) =>
|
||||
mode is PublicationMode.Catalog or PublicationMode.Both;
|
||||
|
||||
public static bool IsVisibleToClubMembers(this PublicationMode mode) =>
|
||||
mode is PublicationMode.ClubOnly or PublicationMode.Both;
|
||||
|
||||
public static string ToDatabaseValue(this PublicationMode mode) =>
|
||||
mode switch
|
||||
{
|
||||
PublicationMode.None => NoneValue,
|
||||
PublicationMode.Catalog => CatalogValue,
|
||||
PublicationMode.ClubOnly => ClubOnlyValue,
|
||||
PublicationMode.Both => BothValue,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown publication mode.")
|
||||
};
|
||||
|
||||
public static PublicationMode FromDatabaseValue(string? value) =>
|
||||
value switch
|
||||
{
|
||||
null or "" => PublicationMode.None,
|
||||
NoneValue => PublicationMode.None,
|
||||
CatalogValue => PublicationMode.Catalog,
|
||||
ClubOnlyValue => PublicationMode.ClubOnly,
|
||||
BothValue => PublicationMode.Both,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown publication mode.")
|
||||
};
|
||||
}
|
||||
@@ -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. Fetch session 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<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
@@ -49,6 +54,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);
|
||||
|
||||
@@ -62,7 +68,21 @@ public sealed class DeleteSessionHandler(
|
||||
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
// 3. 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);
|
||||
|
||||
// 4. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
|
||||
@@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto(
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
</svg>
|
||||
Профиль
|
||||
</NavLink>
|
||||
|
||||
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18"/>
|
||||
<path d="M5 21V7l8-4v18"/>
|
||||
<path d="M19 21V11l-6-4"/>
|
||||
</svg>
|
||||
Мои клубы
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
@@ -73,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.5.1</div>
|
||||
<div class="nav-version">v3.7.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
<div class="public-topbar-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
@page "/group/{GroupId:guid}/applications"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using System.Security.Claims
|
||||
|
||||
<PageTitle>Заявки участников — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="@($"/group/{GroupId}")">Группа</a></li>
|
||||
<li class="active">Заявки</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📨 Заявки участников</h2>
|
||||
<p>Одобряйте или отклоняйте заявки на участие в клубе.</p>
|
||||
</div>
|
||||
|
||||
@if (accessDenied)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>Только owner или co-GM группы могут просматривать заявки.</p>
|
||||
</div>
|
||||
}
|
||||
else if (applications is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 90%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (applications.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Новых заявок нет</h2>
|
||||
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="application-list">
|
||||
@foreach (var app in applications)
|
||||
{
|
||||
<li class="glass-card application-item">
|
||||
<div class="application-info">
|
||||
<strong>@app.DisplayName</strong>
|
||||
<span class="status-badge status-neutral">@app.Platform</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
|
||||
{
|
||||
<span class="application-meta">@app.ExternalUsername</span>
|
||||
}
|
||||
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.Message))
|
||||
{
|
||||
<p class="application-message">«@app.Message»</p>
|
||||
}
|
||||
</div>
|
||||
<div class="application-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
|
||||
✅ Одобрить
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
|
||||
❌ Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private List<WebPendingApplication>? applications;
|
||||
private bool accessDenied;
|
||||
private string? errorMessage;
|
||||
private Guid? busyMembershipId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
accessDenied = false;
|
||||
try
|
||||
{
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Approve(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.ApproveForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Reject(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.RejectForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,16 @@
|
||||
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Режим публикации</label>
|
||||
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
|
||||
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
|
||||
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
|
||||
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
|
||||
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||
@@ -104,6 +114,7 @@
|
||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||
model.JoinLink = session.JoinLink;
|
||||
model.MaxPlayers = session.MaxPlayers;
|
||||
model.PublicationMode = session.PublicationMode;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
@@ -123,6 +134,7 @@
|
||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||
|
||||
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
|
||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -147,5 +159,6 @@
|
||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||
public string JoinLink { get; set; } = "";
|
||||
public int? MaxPlayers { get; set; }
|
||||
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Проведённые сессии — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@GroupId">Группа</a></li>
|
||||
<li class="active">Проведённые сессии</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Проведённые сессии</h2>
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">
|
||||
Добавьте проведённые игры в портфолио — система создаст черновик и предложит заполнить детали.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessions is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 55%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (sessions.Count == 0)
|
||||
{
|
||||
<div class="glass-card animate-slide-up">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">Проведённых сессий пока нет</div>
|
||||
<p class="empty-state-text">Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-completed-list animate-slide-up">
|
||||
@foreach (var session in sessions)
|
||||
{
|
||||
<div class="portfolio-completed-row">
|
||||
<div class="portfolio-completed-info">
|
||||
<a href="/session/@session.Id/history" class="portfolio-completed-title">@session.Title</a>
|
||||
<span class="portfolio-completed-date">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</div>
|
||||
<div class="portfolio-completed-actions">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@(creatingDraftSessionId == session.Id)" @onclick="() => AddToPortfolio(session.Id)">
|
||||
@(creatingDraftSessionId == session.Id ? "⏳..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private IReadOnlyList<PortfolioSessionOption>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
@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 AuthorizedMembershipService MembershipService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -124,6 +127,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingApplicationsCount > 0)
|
||||
{
|
||||
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
|
||||
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
|
||||
<span>Рассмотреть заявки на участие в клубе</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -138,6 +149,60 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames is not null)
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые приключения</h3>
|
||||
<p>Черновики и опубликованные приключения для каталога мастера.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@isCreatingDraft" @onclick="CreateDraft">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Создать")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (portfolioGames.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Приключений пока нет</div>
|
||||
<p class="empty-state-text">Создайте первый черновик и добавьте проведённые сессии.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-management-list">
|
||||
@foreach (var game in portfolioGames)
|
||||
{
|
||||
<div class="portfolio-management-row">
|
||||
<div class="portfolio-management-info">
|
||||
<a href="/portfolio/manage/@game.Id" class="portfolio-management-title">@game.Title</a>
|
||||
<span class="status-badge @(game.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(game.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
<div class="portfolio-management-meta">
|
||||
<span class="status-badge status-info">@game.SessionCount игр</span>
|
||||
<span class="status-badge status-info">@game.MasterCount мастеров</span>
|
||||
@if (game.PendingReviewCount > 0)
|
||||
{
|
||||
<span class="status-badge status-warning">@game.PendingReviewCount на модерации</span>
|
||||
}
|
||||
</div>
|
||||
<div class="portfolio-management-actions">
|
||||
<a href="/portfolio/manage/@game.Id" class="btn-gm btn-gm-outline">✏️ Изменить</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<a href="/group/@GroupId/completed" class="btn-gm btn-gm-outline">📜 Все проведённые сессии</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (campaignTemplates is not null)
|
||||
{
|
||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||
@@ -257,11 +322,12 @@
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
|
||||
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
@@ -313,11 +379,12 @@
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
@@ -410,11 +477,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
@@ -481,8 +549,10 @@
|
||||
private List<WebCampaignTemplate>? campaignTemplates;
|
||||
private WebGroupManagement? groupManagement;
|
||||
private WebPublicGroupSettings? publicSettings;
|
||||
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private int pendingApplicationsCount;
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
@@ -490,6 +560,7 @@
|
||||
private Guid? publishingSessionId;
|
||||
private string? removingCoGmId;
|
||||
private bool isAddingCoGm;
|
||||
private bool isCreatingDraft;
|
||||
private bool savingPublicSettings;
|
||||
private string? currentPlatform;
|
||||
private string? externalUserId;
|
||||
@@ -545,11 +616,40 @@
|
||||
return;
|
||||
}
|
||||
|
||||
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||
|
||||
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(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;
|
||||
@@ -579,7 +679,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -587,10 +687,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
|
||||
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
|
||||
_ => "Batch скрыт из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -607,7 +711,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -615,10 +719,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
|
||||
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
|
||||
_ => "Сессия скрыта из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -988,7 +1096,13 @@
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic),
|
||||
PublicationMode = orderedSessions
|
||||
.Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode))
|
||||
.GroupBy(m => m)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First()
|
||||
.Key
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -1135,6 +1249,9 @@
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static PublicationMode ParseMode(object? value) =>
|
||||
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
|
||||
|
||||
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
||||
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
|
||||
|
||||
@@ -1187,6 +1304,7 @@
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@page "/profile/memberships"
|
||||
@using GmRelay.Web.Services
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Мои клубы — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Мои клубы</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>🏛 Мои клубы</h2>
|
||||
<p>Заявки и активные участия в приватных клубных витринах.</p>
|
||||
</div>
|
||||
|
||||
@if (memberships is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (memberships.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Вы пока не подавали заявок</h2>
|
||||
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
|
||||
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (activeMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up">
|
||||
<h3>Активные участия</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in activeMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
|
||||
@membership.GroupName
|
||||
</a>
|
||||
<span class="status-badge status-success">Участник</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Покинуть клуб
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pendingMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>Заявки на рассмотрении</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in pendingMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge status-warning">Ожидает одобрения</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Отозвать заявку
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (historyMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>История</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in historyMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
|
||||
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
|
||||
</span>
|
||||
@if (membership.DecidedAt is not null)
|
||||
{
|
||||
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<WebMembership>? memberships;
|
||||
private List<WebMembership> activeMemberships = [];
|
||||
private List<WebMembership> pendingMemberships = [];
|
||||
private List<WebMembership> historyMemberships = [];
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
errorMessage = null;
|
||||
memberships = await MembershipService.GetMineAsync();
|
||||
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
|
||||
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
|
||||
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
|
||||
}
|
||||
|
||||
private async Task Leave(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
try
|
||||
{
|
||||
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Портфолио — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/group/@groupId">Группа</a></li>
|
||||
<li class="active">Портфолио</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📚 Управление портфолио</h2>
|
||||
@if (editor is not null)
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@editor.Title</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||
✅ @successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editor is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 0.75rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Параметры публикации</h3>
|
||||
<p>Управление видимостью и обложкой приключения.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.IsPublic ? "status-success" : "status-neutral")">
|
||||
@(editor.IsPublic ? "Опубликовано" : "Черновик")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-grid">
|
||||
<div class="portfolio-editor-cover">
|
||||
@if (!string.IsNullOrEmpty(editor.CoverPath))
|
||||
{
|
||||
<img src="@editor.CoverPath" alt="Обложка" class="portfolio-editor-cover-image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-editor-cover-empty">Обложка не загружена</div>
|
||||
}
|
||||
<InputFile OnChange="HandleFileSelected" accept="image/jpeg,image/png,image/webp" class="portfolio-editor-cover-input" />
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@isUploadingCover" @onclick="TriggerCoverUpload">
|
||||
@(isUploadingCover ? "⏳ Загружаем..." : "🖼 Заменить обложку")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-fields">
|
||||
<EditForm Model="@editorModel" OnValidSubmit="SaveDraft">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Название</label>
|
||||
<InputText @bind-Value="editorModel.Title" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес (slug)</label>
|
||||
<InputText @bind-Value="editorModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-run".</div>
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Система</label>
|
||||
<InputText @bind-Value="editorModel.System" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Формат</label>
|
||||
<InputText @bind-Value="editorModel.Format" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="editorModel.Description" class="gm-form-control" />
|
||||
</div>
|
||||
|
||||
<div class="portfolio-editor-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSaving">
|
||||
@(isSaving ? "⏳ Сохраняем..." : "💾 Сохранить")
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<div class="portfolio-editor-publish-row">
|
||||
<button type="button" class="btn-gm @(editor.IsPublic ? "btn-gm-outline" : "btn-gm-success")" disabled="@isUpdatingPublication" @onclick="() => SetPublication(!editor.IsPublic)">
|
||||
@(isUpdatingPublication
|
||||
? "Обновляем..."
|
||||
: editor.IsPublic ? "Скрыть из каталога" : "Опубликовать")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@isDeleting" @onclick="DeletePortfolio">
|
||||
@(isDeleting ? "⏳ Удаляем..." : "🗑 Удалить")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Проведённые сессии</h3>
|
||||
<p>Отметьте игры, которые вошли в это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.SessionIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var session in editor.Sessions)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@session.Selected" @onchange="e => ToggleSession(session.Id, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@session.Title</span>
|
||||
<span class="portfolio-option-meta">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Мастера приключения</h3>
|
||||
<p>Выберите мастеров, которые вели это приключение.</p>
|
||||
</div>
|
||||
<span class="status-badge status-info">@editorModel.MasterPlayerIds.Count</span>
|
||||
</div>
|
||||
<div class="portfolio-option-list">
|
||||
@foreach (var master in editor.Masters)
|
||||
{
|
||||
<label class="portfolio-option-row">
|
||||
<input type="checkbox" checked="@master.Selected" @onchange="e => ToggleMaster(master.PlayerId, (bool)(e.Value ?? false))" />
|
||||
<span class="portfolio-option-title">@master.DisplayName</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||
<div class="batch-bulk-header">
|
||||
<div>
|
||||
<h3>Модерация отзывов</h3>
|
||||
<p>Одобрите, отклоните или скройте отзывы игроков перед публикацией.</p>
|
||||
</div>
|
||||
<span class="status-badge @(editor.Reviews.Any(r => r.ModerationStatus == "Pending") ? "status-warning" : "status-neutral")">
|
||||
@editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (editor.Reviews.Count == 0)
|
||||
{
|
||||
<div class="empty-state empty-state-compact">
|
||||
<div class="empty-state-title">Отзывов пока нет</div>
|
||||
<p class="empty-state-text">Игроки смогут оставить отзыв после публикации приключения.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-review-moderation">
|
||||
@foreach (var review in editor.Reviews)
|
||||
{
|
||||
<div class="portfolio-review-row">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="status-badge @GetReviewStatusClass(review.ModerationStatus)">@TranslateReviewStatus(review.ModerationStatus)</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
<div class="portfolio-review-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Approved"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Одобрить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Rejected"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Отклонить")
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Hidden"))">
|
||||
@(moderatingReviewId == review.Id ? "⏳..." : "Скрыть")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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<Guid> SessionIds { get; set; } = new();
|
||||
public List<Guid> MasterPlayerIds { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
@page "/club/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@using System.Security.Claims
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -58,22 +64,88 @@ else if (club is not null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.Sessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
|
||||
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
|
||||
|
||||
@if (publicSessions.Count > 0)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in publicSessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (membersOnlySessions.Count > 0)
|
||||
{
|
||||
<section class="glass-card members-only-section">
|
||||
<h2>Игры для участников клуба</h2>
|
||||
@if (viewerIsActiveMember)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in membersOnlySessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge status-warning">Только для участников</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Эти сессии доступны только одобренным участникам клуба.</p>
|
||||
@if (viewerPlayerId is null)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<details class="application-form">
|
||||
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
|
||||
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
|
||||
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(applicationError))
|
||||
{
|
||||
<p class="form-error">@applicationError</p>
|
||||
}
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
|
||||
</EditForm>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Завершённые игры клуба</h2>
|
||||
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +153,35 @@ else if (club is not null)
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private WebPublicClub? club;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
private Guid? viewerPlayerId;
|
||||
private bool viewerIsActiveMember;
|
||||
private string? applicationError;
|
||||
private string? applicationMessage;
|
||||
private bool isSubmittingApplication;
|
||||
|
||||
private async Task TrySubmitApplicationAsync()
|
||||
{
|
||||
applicationError = null;
|
||||
if (club is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isSubmittingApplication = true;
|
||||
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
|
||||
applicationMessage = null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
applicationError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmittingApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
@@ -93,9 +193,42 @@ 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();
|
||||
applicationError = null;
|
||||
applicationMessage = null;
|
||||
|
||||
// Resolve viewer identity (player id) for member-aware access.
|
||||
var user = HttpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
||||
{
|
||||
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
|
||||
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
|
||||
var platform = user.FindFirst("Platform")?.Value;
|
||||
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
||||
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerPlayerId = null;
|
||||
}
|
||||
|
||||
club = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
|
||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
||||
|
||||
if (club is not null && viewerPlayerId is not null)
|
||||
{
|
||||
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerIsActiveMember = false;
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
@page "/gm/{Slug}"
|
||||
@layout PublicLayout
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@@ -83,12 +87,22 @@ else if (profile is not null)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
{
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Портфолио</h2>
|
||||
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
|
||||
<PortfolioCardGrid Games="portfolioGames" />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Slug { get; set; }
|
||||
|
||||
private GmRelay.Web.Services.PublicMasterProfile? profile;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
|
||||
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
|
||||
@@ -101,9 +115,24 @@ 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();
|
||||
|
||||
Guid? viewerPlayerId = null;
|
||||
var user = HttpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
||||
{
|
||||
var platform = user.FindFirst("Platform")?.Value;
|
||||
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
||||
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
||||
: null;
|
||||
}
|
||||
|
||||
profile = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>@PageTitleText</PageTitle>
|
||||
|
||||
@if (loaded && game is null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</HeadContent>
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-neutral">Недоступно</span>
|
||||
<h1>Портфолио не найдено</h1>
|
||||
<p>Эта игра скрыта, ещё не опубликована или короткий адрес больше не используется.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!loaded)
|
||||
{
|
||||
<section class="public-hero public-hero-compact">
|
||||
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
||||
</section>
|
||||
}
|
||||
else if (game is not null)
|
||||
{
|
||||
<HeadContent>
|
||||
<meta name="description" content="@($"Портфолио {game.Title} — завершённая игра в GM-Relay.")" />
|
||||
</HeadContent>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-cover-hero" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
|
||||
<section class="public-hero public-hero-compact">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<h1>@game.Title</h1>
|
||||
<p>Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()</p>
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="glass-card public-session-detail">
|
||||
@if (!string.IsNullOrWhiteSpace(game.Description))
|
||||
{
|
||||
<div class="session-description">
|
||||
<h3>Описание</h3>
|
||||
<p>@game.Description</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.Masters.Count > 0)
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастера</span>
|
||||
@foreach (var master in game.Masters)
|
||||
{
|
||||
<a href="@($"/gm/{master.Slug}")">@master.DisplayName</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Клуб</span>
|
||||
<a href="@($"/club/{game.ClubSlug}")">@game.ClubName</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicPortfolioUrl" target="_blank" rel="noopener noreferrer">Ссылка на портфолио</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Отзывы игроков</h2>
|
||||
@if (game.Reviews.Count == 0)
|
||||
{
|
||||
<p>Пока нет одобренных отзывов.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="portfolio-review-list">
|
||||
@foreach (var review in game.Reviews)
|
||||
{
|
||||
<li class="portfolio-review-card">
|
||||
<div class="portfolio-review-meta">
|
||||
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||
<span class="portfolio-review-date">@review.CreatedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
<p class="portfolio-review-body">@review.Body</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="glass-card portfolio-section">
|
||||
<h2>Оставить отзыв</h2>
|
||||
@switch (submissionState)
|
||||
{
|
||||
case PortfolioReviewSubmissionState.RequiresAuthentication:
|
||||
<p>Войдите, чтобы оставить отзыв об этом приключении.</p>
|
||||
<div class="public-settings-actions">
|
||||
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти</a>
|
||||
</div>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Ineligible:
|
||||
<p>Отзыв могут оставить только игроки, участвовавшие в этом приключении.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.AlreadySubmitted:
|
||||
<p>Отзыв отправлен на модерацию.</p>
|
||||
break;
|
||||
case PortfolioReviewSubmissionState.Eligible:
|
||||
<EditForm Model="reviewModel" OnValidSubmit="SubmitReviewAsync" FormName="portfolio-review">
|
||||
<div class="portfolio-editor-fields">
|
||||
<label>
|
||||
<span>Текст отзыва</span>
|
||||
<textarea class="portfolio-review-textarea"
|
||||
@bind="reviewModel.Body"
|
||||
@bind:event="oninput"
|
||||
maxlength="2000"
|
||||
minlength="10"
|
||||
rows="5"
|
||||
placeholder="Что вам запомнилось в этой игре?"
|
||||
required></textarea>
|
||||
</label>
|
||||
<label class="portfolio-review-consent">
|
||||
<input type="checkbox"
|
||||
name="publicationConsent"
|
||||
@bind="reviewModel.PublicationConsent"
|
||||
required />
|
||||
<span>Я даю согласие на публикацию этого отзыва</span>
|
||||
</label>
|
||||
@if (!string.IsNullOrWhiteSpace(submissionError))
|
||||
{
|
||||
<p class="portfolio-review-error">@submissionError</p>
|
||||
}
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "Отправка..." : "Отправить отзыв")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
break;
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@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<GameSystem>(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; }
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
{
|
||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
|
||||
}
|
||||
@if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow)
|
||||
{
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<button type="button" class="btn-gm btn-gm-primary" disabled="@isCreatingDraft" @onclick="AddToPortfolio">
|
||||
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Добавить в портфолио")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entries is null)
|
||||
@@ -78,6 +88,8 @@
|
||||
private List<SessionAuditLogEntry>? 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" => "Название",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@using GmRelay.Shared.Domain
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
<div class="portfolio-grid">
|
||||
@foreach (var game in Games)
|
||||
{
|
||||
<a class="portfolio-card" href="@($"/portfolio/{game.Slug}")">
|
||||
@if (!string.IsNullOrWhiteSpace(game.CoverPath))
|
||||
{
|
||||
<div class="portfolio-card-cover" style="background-image: url('@game.CoverPath')"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="portfolio-card-cover portfolio-card-cover-empty">
|
||||
<span>Без обложки</span>
|
||||
</div>
|
||||
}
|
||||
<div class="portfolio-card-body">
|
||||
<h3>@game.Title</h3>
|
||||
<div class="portfolio-card-meta">
|
||||
<span class="status-badge status-success">Завершено</span>
|
||||
<span class="portfolio-card-date">@game.CompletedAt.ToLocalTime().FormatMoscowShort()</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(game.System) || !string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<div class="portfolio-card-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(game.System))
|
||||
{
|
||||
<span class="status-badge status-info">@GetSystemDisplayName(game.System)</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(game.Format))
|
||||
{
|
||||
<span class="status-badge status-neutral">@TranslateFormat(game.Format)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<PublicPortfolioCard> Games { get; set; } = [];
|
||||
|
||||
private static string GetSystemDisplayName(string? system)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(system))
|
||||
return system ?? string.Empty;
|
||||
|
||||
if (Enum.TryParse<GameSystem>(system, out var gs))
|
||||
return gs.ToDisplayName();
|
||||
|
||||
return system;
|
||||
}
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
"Offline" => "Офлайн",
|
||||
"Hybrid" => "Гибрид",
|
||||
_ => format
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@@ -37,12 +39,16 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
||||
|
||||
// Add Services
|
||||
builder.Services.AddSingleton<TelegramAuthService>();
|
||||
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
|
||||
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||
builder.Services.AddSingleton<DiscordAuthService>();
|
||||
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||
builder.Services.AddScoped<AuthorizedMembershipService>();
|
||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
||||
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
||||
|
||||
// Add Bot Client
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
@@ -94,6 +100,8 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UsePortfolioCoverFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Claims;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, 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 ?? externalUserId;
|
||||
return (platform, externalUserId, name);
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
}
|
||||
|
||||
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
|
||||
if (normalizedMessage?.Length > 1000)
|
||||
{
|
||||
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
|
||||
}
|
||||
|
||||
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMineAsync()
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return [];
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
return [];
|
||||
|
||||
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
|
||||
}
|
||||
|
||||
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
|
||||
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return await sessionStore.GetPendingApplicationsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return 0;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return 0;
|
||||
|
||||
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task ApproveForCurrentGmAsync(Guid membershipId)
|
||||
{
|
||||
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
||||
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
|
||||
}
|
||||
|
||||
public async Task RejectForCurrentGmAsync(Guid membershipId)
|
||||
{
|
||||
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
|
||||
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
|
||||
}
|
||||
|
||||
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
throw new InvalidOperationException("Player record not found for current user.");
|
||||
|
||||
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
|
||||
if (groupId is null)
|
||||
throw new InvalidOperationException($"Membership {membershipId} not found.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (playerId.Value, groupId.Value);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
normalizedBio);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
|
||||
}
|
||||
|
||||
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return false;
|
||||
|
||||
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
if (playerId is null)
|
||||
return false;
|
||||
|
||||
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
|
||||
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue,
|
||||
bool IsMembersOnly = false,
|
||||
string? MasterProfileSlug = null,
|
||||
string? MasterDisplayName = null);
|
||||
|
||||
public sealed record WebMembership(
|
||||
Guid MembershipId,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Status,
|
||||
string Role,
|
||||
string? Message,
|
||||
DateTime AppliedAt,
|
||||
DateTime? DecidedAt,
|
||||
string? DecidedByDisplayName);
|
||||
|
||||
public sealed record WebPendingApplication(
|
||||
Guid MembershipId,
|
||||
Guid PlayerId,
|
||||
string DisplayName,
|
||||
string Platform,
|
||||
string? ExternalUsername,
|
||||
string? Message,
|
||||
DateTime AppliedAt);
|
||||
|
||||
public sealed record WebClubShowcaseSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
string PublicationMode,
|
||||
bool IsMembersOnly,
|
||||
string? Description,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
@@ -79,12 +120,14 @@ public interface ISessionStore
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
|
||||
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
|
||||
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
@@ -110,7 +153,7 @@ public interface ISessionStore
|
||||
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
|
||||
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
|
||||
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
|
||||
|
||||
// --- Identity linking (issue #35) ---
|
||||
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
|
||||
@@ -123,6 +166,17 @@ public interface ISessionStore
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
|
||||
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
|
||||
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
|
||||
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
|
||||
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
|
||||
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
|
||||
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
|
||||
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
|
||||
}
|
||||
|
||||
public sealed record LinkedIdentity(
|
||||
|
||||
@@ -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, "<anonymous>");
|
||||
}
|
||||
|
||||
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, "<anonymous>");
|
||||
}
|
||||
|
||||
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<IReadOnlyList<PortfolioGameSummary>> 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<PortfolioGameEditor?> 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<IReadOnlyList<PortfolioSessionOption>> 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<Guid> 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<PortfolioReviewSubmissionState> 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, "<anonymous>");
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);
|
||||
|
||||
public interface IPortfolioCoverStorage
|
||||
{
|
||||
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
|
||||
|
||||
string GetPublicPath(string storageKey);
|
||||
}
|
||||
@@ -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<LocalPortfolioCoverStorage> _logger;
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options)
|
||||
: this(options, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger<LocalPortfolioCoverStorage>? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.StoragePath))
|
||||
{
|
||||
throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured.");
|
||||
}
|
||||
|
||||
_storagePath = options.StoragePath;
|
||||
_logger = logger ?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
}
|
||||
|
||||
public async Task<PortfolioCoverUploadResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PortfolioCoverStorageOptions>()
|
||||
.Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName))
|
||||
.Validate(
|
||||
o => !string.IsNullOrWhiteSpace(o.StoragePath),
|
||||
"PortfolioCovers:StoragePath must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IPortfolioCoverStorage>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<
|
||||
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
|
||||
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<LocalPortfolioCoverStorage>()
|
||||
?? NullLogger<LocalPortfolioCoverStorage>.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<PortfolioCoverStorageOptions>>().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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public interface IPortfolioStore
|
||||
{
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
|
||||
|
||||
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
|
||||
|
||||
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
|
||||
|
||||
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
|
||||
|
||||
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
|
||||
|
||||
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||
|
||||
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
|
||||
|
||||
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
|
||||
|
||||
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
|
||||
|
||||
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
|
||||
|
||||
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
|
||||
|
||||
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
|
||||
|
||||
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
|
||||
|
||||
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
|
||||
}
|
||||
@@ -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<PublicPortfolioMaster> Masters,
|
||||
IReadOnlyList<PublicPortfolioReview> 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<PortfolioSessionOption> Sessions,
|
||||
IReadOnlyList<PortfolioMasterOption> Masters,
|
||||
IReadOnlyList<PortfolioReviewForModeration> Reviews);
|
||||
|
||||
public sealed record PortfolioGameUpdate(
|
||||
string Title,
|
||||
string? PublicSlug,
|
||||
string? Description,
|
||||
string? System,
|
||||
string? Format,
|
||||
IReadOnlyList<Guid> SessionIds,
|
||||
IReadOnlyList<Guid> MasterPlayerIds);
|
||||
|
||||
public enum PortfolioReviewSubmissionState
|
||||
{
|
||||
RequiresAuthentication,
|
||||
Ineligible,
|
||||
Eligible,
|
||||
AlreadySubmitted
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string> 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');
|
||||
}
|
||||
@@ -69,7 +69,19 @@ public sealed record WebSession(
|
||||
int WaitlistedPlayerCount,
|
||||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||||
int? ThreadId = null,
|
||||
bool IsPublic = false);
|
||||
string PublicationMode = PublicationModeExtensions.NoneValue)
|
||||
{
|
||||
public bool IsPublic
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
|
||||
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
|
||||
}
|
||||
|
||||
public sealed record WebParticipant(
|
||||
Guid Id,
|
||||
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
|
||||
bool AllowDirectRegistration,
|
||||
string? Description,
|
||||
string? MasterProfileSlug,
|
||||
string? MasterDisplayName);
|
||||
string? MasterDisplayName,
|
||||
string PublicationMode = "None",
|
||||
bool IsMembersOnly = false);
|
||||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||||
|
||||
public sealed class SessionService(
|
||||
@@ -233,7 +247,7 @@ public sealed class SessionService(
|
||||
SELECT COUNT(*) AS count
|
||||
FROM sessions s
|
||||
WHERE s.group_id = g.id
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
) public_counts ON true
|
||||
WHERE g.id = @GroupId
|
||||
""",
|
||||
@@ -266,18 +280,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -285,18 +299,18 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var updatedRows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET is_public = @IsPublic,
|
||||
SET publication_mode = @Mode,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
AND group_id = @GroupId
|
||||
""",
|
||||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||||
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||||
|
||||
if (updatedRows == 0)
|
||||
{
|
||||
@@ -304,7 +318,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||||
@@ -345,11 +359,11 @@ public sealed class SessionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
|
||||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
|
||||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||||
@@ -364,6 +378,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -404,9 +420,21 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
""",
|
||||
new
|
||||
{
|
||||
@@ -414,7 +442,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +508,7 @@ public sealed class SessionService(
|
||||
AND mp.public_slug IS NOT NULL
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
@@ -518,7 +547,10 @@ public sealed class SessionService(
|
||||
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
r.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||
@@ -583,7 +615,7 @@ public sealed class SessionService(
|
||||
WHERE s.id = @SessionId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
""",
|
||||
@@ -603,7 +635,10 @@ public sealed class SessionService(
|
||||
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||
row.Description, row.MasterProfileSlug, row.MasterDisplayName);
|
||||
row.Description,
|
||||
PublicationMode: "Catalog",
|
||||
IsMembersOnly: false,
|
||||
row.MasterProfileSlug, row.MasterDisplayName);
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||
@@ -617,7 +652,7 @@ public sealed class SessionService(
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.is_public = true
|
||||
AND s.publication_mode IN ('Catalog', 'Both')
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
@@ -868,7 +903,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -907,7 +942,7 @@ public sealed class SessionService(
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -967,7 +1002,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -1054,7 +1089,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1181,7 +1216,7 @@ public sealed class SessionService(
|
||||
0 AS WaitlistedPlayerCount,
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.thread_id AS ThreadId,
|
||||
s.is_public AS IsPublic
|
||||
s.publication_mode AS PublicationMode
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
@@ -1951,7 +1986,7 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
|
||||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||||
@@ -1971,7 +2006,7 @@ public sealed class SessionService(
|
||||
return null;
|
||||
|
||||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
|
||||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
|
||||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||||
}
|
||||
|
||||
@@ -2004,7 +2039,8 @@ public sealed class SessionService(
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid playerId)
|
||||
Guid playerId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2018,6 +2054,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2051,9 +2089,21 @@ public sealed class SessionService(
|
||||
) waitlist_counts ON true
|
||||
WHERE g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2061,13 +2111,15 @@ public sealed class SessionService(
|
||||
PlayerId = playerId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||||
NpgsqlConnection conn,
|
||||
Guid groupId)
|
||||
Guid groupId,
|
||||
Guid? viewerPlayerId)
|
||||
{
|
||||
return (await conn.QueryAsync<WebPublicSession>(
|
||||
"""
|
||||
@@ -2081,6 +2133,8 @@ public sealed class SessionService(
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
mp.public_slug AS MasterProfileSlug,
|
||||
mp.display_name AS MasterDisplayName
|
||||
FROM sessions s
|
||||
@@ -2121,9 +2175,21 @@ public sealed class SessionService(
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.is_public = true
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at
|
||||
""",
|
||||
new
|
||||
@@ -2132,7 +2198,8 @@ public sealed class SessionService(
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||
ViewerPlayerId = viewerPlayerId
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
@@ -2432,4 +2499,248 @@ public sealed class SessionService(
|
||||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
// --- Private club showcases / memberships (issue #110) ---
|
||||
|
||||
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
"""
|
||||
SELECT COUNT(*) FROM club_memberships
|
||||
WHERE group_id = @GroupId
|
||||
AND player_id = @PlayerId
|
||||
AND status = 'Active'
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||||
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
s.system AS System,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.format AS Format,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.cover_image_url AS CoverImageUrl,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||
s.publication_mode AS PublicationMode,
|
||||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||||
s.description AS Description,
|
||||
s.allow_direct_registration AS AllowDirectRegistration
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId
|
||||
AND g.public_schedule_enabled = true
|
||||
AND g.public_slug IS NOT NULL
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
AND s.status <> @Cancelled
|
||||
AND (
|
||||
s.publication_mode IN ('Catalog', 'Both')
|
||||
OR (
|
||||
s.publication_mode = 'ClubOnly'
|
||||
AND @ViewerPlayerId IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_memberships cm
|
||||
WHERE cm.group_id = s.group_id
|
||||
AND cm.player_id = @ViewerPlayerId
|
||||
AND cm.status = 'Active'
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY s.scheduled_at ASC
|
||||
OFFSET @Offset LIMIT @PageSize
|
||||
""",
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
ViewerPlayerId = viewerPlayerId,
|
||||
Offset = page * pageSize,
|
||||
PageSize = pageSize
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND status = 'Pending'
|
||||
""",
|
||||
new { GroupId = groupId });
|
||||
}
|
||||
|
||||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebPendingApplication>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.platform AS Platform,
|
||||
p.external_username AS ExternalUsername,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt
|
||||
FROM club_memberships cm
|
||||
JOIN players p ON p.id = cm.player_id
|
||||
WHERE cm.group_id = @GroupId
|
||||
AND cm.status = 'Pending'
|
||||
ORDER BY cm.applied_at ASC
|
||||
""",
|
||||
new { GroupId = groupId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return (await conn.QueryAsync<WebMembership>(
|
||||
"""
|
||||
SELECT cm.id AS MembershipId,
|
||||
cm.group_id AS GroupId,
|
||||
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||||
g.public_slug AS GroupSlug,
|
||||
cm.status AS Status,
|
||||
cm.role AS Role,
|
||||
cm.message AS Message,
|
||||
cm.applied_at AS AppliedAt,
|
||||
cm.decided_at AS DecidedAt,
|
||||
decider.display_name AS DecidedByDisplayName
|
||||
FROM club_memberships cm
|
||||
JOIN game_groups g ON g.id = cm.group_id
|
||||
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||||
WHERE cm.player_id = @PlayerId
|
||||
ORDER BY cm.applied_at DESC
|
||||
""",
|
||||
new { PlayerId = playerId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var existing = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)::int FROM club_memberships
|
||||
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId });
|
||||
if (existing > 0)
|
||||
{
|
||||
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||||
}
|
||||
|
||||
return await conn.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||||
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||||
RETURNING id
|
||||
""",
|
||||
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||||
}
|
||||
|
||||
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||||
WHERE id = @MembershipId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
// Active membership: withdraw by setting status = 'Left'.
|
||||
var rows = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Left', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (rows > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
|
||||
var cancelled = await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE club_memberships
|
||||
SET status = 'Rejected', decided_at = now()
|
||||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
|
||||
""",
|
||||
new { MembershipId = membershipId, PlayerId = playerId });
|
||||
if (cancelled == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||||
"""
|
||||
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||||
""",
|
||||
new { MembershipId = membershipId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"PortfolioCovers": {
|
||||
"StoragePath": "../../artifacts/portfolio-covers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2021,3 +2021,427 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class AuthorizedMembershipServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||
|
||||
Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("User is not authenticated", service, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizedMembershipService_ShouldValidateMessageLength()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||
|
||||
Assert.Contains("1000", service, StringComparison.Ordinal);
|
||||
Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||
|
||||
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizedMembershipService_ShouldExposePendingApplications()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||
|
||||
Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("GetMineAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("LeaveClubForCurrentUserAsync", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
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<Claim>
|
||||
{
|
||||
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<Guid, Guid?>
|
||||
{
|
||||
[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<SessionAccessDeniedException>(
|
||||
() => 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<Guid, Guid?> { [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<Guid, Guid?> { [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<InvalidOperationException>(
|
||||
() => 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<Guid, Guid?> { [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<Guid, Guid?> { [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<Guid, Guid?> { [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<SessionAccessDeniedException>(
|
||||
() => 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<Guid, Guid?> { [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<Guid, Guid?> { [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<Guid, Guid?> { [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<SessionAccessDeniedException>(
|
||||
() => 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<Guid, Guid?> { [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<Guid, Guid?> { [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<Guid, Guid?> { [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<SessionAccessDeniedException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => 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<Guid, Guid?> { [portfolioGameId] = null }
|
||||
};
|
||||
var sessionStore = new FakeSessionStore();
|
||||
var accessor = CreateAccessor("1001");
|
||||
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.DeleteForCurrentUserAsync(portfolioGameId));
|
||||
}
|
||||
|
||||
// --- Fakes ---
|
||||
|
||||
private sealed class FakePortfolioStore : IPortfolioStore
|
||||
{
|
||||
public Dictionary<Guid, Guid?> PortfolioGameGroupIds { get; set; } = new();
|
||||
|
||||
public Dictionary<Guid, Guid> 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<PortfolioSessionOption> EligibleSessions { get; set; } = [];
|
||||
public Guid? LastEligibleGroupId { get; private set; }
|
||||
|
||||
public IReadOnlyList<PortfolioGameSummary> 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<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug) =>
|
||||
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
||||
|
||||
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug) =>
|
||||
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
||||
|
||||
public Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug) =>
|
||||
Task.FromResult<PublicPortfolioGame?>(null);
|
||||
|
||||
public Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
|
||||
{
|
||||
LastEligibleGroupId = groupId;
|
||||
return Task.FromResult(GamesForGroup);
|
||||
}
|
||||
|
||||
public Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
|
||||
{
|
||||
PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId);
|
||||
return Task.FromResult(groupId);
|
||||
}
|
||||
|
||||
public Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId) =>
|
||||
Task.FromResult(EditorResult);
|
||||
|
||||
public Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
|
||||
{
|
||||
LastEligibleGroupId = groupId;
|
||||
return Task.FromResult(EligibleSessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) =>
|
||||
Task.FromResult<IReadOnlyList<PortfolioMasterOption>>([]);
|
||||
|
||||
public Task<Guid> 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<string?> 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<string?> 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<PortfolioReviewSubmissionState> 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<bool> 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<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult(EffectivePlayerId);
|
||||
|
||||
// Unused interface members — throw so accidental use surfaces in test output
|
||||
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
|
||||
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
||||
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
||||
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
|
||||
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
|
||||
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
|
||||
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
|
||||
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
|
||||
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
|
||||
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
|
||||
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
|
||||
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
|
||||
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task<WebSessionBatch?> 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<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException();
|
||||
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException();
|
||||
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException();
|
||||
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException();
|
||||
public Task<WebSessionBatch> 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<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException();
|
||||
public Task<List<PlayerAttendanceStats>> 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<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
||||
public Task<MasterProfileSettings?> 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<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
||||
public Task<List<LinkedIdentity>> 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<IReadOnlyList<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto>> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException();
|
||||
public Task<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
||||
public Task<bool> 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<string> SavedKeys { get; } = new();
|
||||
public List<string> DeletedKeys { get; } = new();
|
||||
|
||||
public Task<PortfolioCoverUploadResult> 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;
|
||||
}
|
||||
}
|
||||
@@ -839,9 +839,11 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
public PublicationMode? LastSessionPublicationMode { get; private set; }
|
||||
public Guid? LastPublicBatchId { get; private set; }
|
||||
public Guid? LastPublicBatchGroupId { get; private set; }
|
||||
public bool? LastBatchPublicValue { get; private set; }
|
||||
public PublicationMode? LastBatchPublicationMode { get; private set; }
|
||||
public bool RemovePlayerCalled { get; private set; }
|
||||
public Guid? LastRemovedPlayerSessionId { get; private set; }
|
||||
public Guid? LastRemovedPlayerGroupId { get; private set; }
|
||||
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||||
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
SetSessionPublicCalled = true;
|
||||
LastPublicSessionId = sessionId;
|
||||
LastPublicSessionGroupId = groupId;
|
||||
LastSessionPublicValue = isPublic;
|
||||
LastSessionPublicationMode = mode;
|
||||
|
||||
if (sessionsById.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
sessionsById[sessionId] = session with { IsPublic = isPublic };
|
||||
sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||||
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||||
{
|
||||
SetBatchPublicCalled = true;
|
||||
LastPublicBatchId = batchId;
|
||||
LastPublicBatchGroupId = groupId;
|
||||
LastBatchPublicValue = isPublic;
|
||||
LastBatchPublicationMode = mode;
|
||||
|
||||
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
|
||||
{
|
||||
sessionsById[session.Id] = session with { IsPublic = isPublic };
|
||||
sessionsById[session.Id] = session with { PublicationMode = mode.ToDatabaseValue() };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
|
||||
Task.FromResult<WebPublicClub?>(null);
|
||||
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
|
||||
Task.FromResult<WebPublicSession?>(null);
|
||||
|
||||
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsManager(groupId, telegramId));
|
||||
|
||||
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult<Guid?>(null);
|
||||
|
||||
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) =>
|
||||
Task.FromResult<IReadOnlyList<WebClubShowcaseSession>>([]);
|
||||
|
||||
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) =>
|
||||
Task.FromResult(new List<WebPendingApplication>());
|
||||
|
||||
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) =>
|
||||
Task.FromResult(new List<WebMembership>());
|
||||
|
||||
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) =>
|
||||
Task.FromResult<Guid?>(null);
|
||||
|
||||
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
|
||||
Task.FromResult<PublicMasterProfile?>(null);
|
||||
|
||||
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
|
||||
Task.FromResult(IsOwner(groupId, telegramId));
|
||||
|
||||
|
||||
@@ -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.7.0", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class ClubMembershipsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SessionStore_ShouldExposeMembershipMethods()
|
||||
{
|
||||
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
||||
|
||||
Assert.Contains("ApplyForMembershipAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("ApproveMembershipAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("RejectMembershipAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("LeaveClubMembershipAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPendingApplicationsAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetMembershipsForPlayerAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("IsActiveClubMemberAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetGroupIdForMembershipAsync", sessionStore, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionService_ShouldFilterPublicSessionsWithMemberAwareClause()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
||||
|
||||
// Member-aware: ClubOnly only visible to Active members
|
||||
Assert.Contains("publication_mode = 'ClubOnly'", service, StringComparison.Ordinal);
|
||||
Assert.Contains("club_memberships", service, StringComparison.Ordinal);
|
||||
Assert.Contains("cm.status = 'Active'", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizedMembershipService_ShouldValidateCallerForGmActions()
|
||||
{
|
||||
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
|
||||
|
||||
Assert.Contains("IsGroupManagerAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
|
||||
Assert.Contains("SessionAccessDeniedException", service, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MyClubMembershipsPage_ShouldRenderLeaveAndCancelButtons()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/MyClubMemberships.razor");
|
||||
|
||||
Assert.Contains("@page \"/profile/memberships\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Покинуть клуб", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Отозвать заявку", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Active", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Pending", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClubApplicationsPage_ShouldRenderApproveAndReject()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/ClubApplications.razor");
|
||||
|
||||
Assert.Contains("/applications", page, StringComparison.Ordinal);
|
||||
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Одобрить", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Отклонить", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicClubPage_ShouldExposeApplicationCtaAndMembersOnlyBlock()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
|
||||
Assert.Contains("viewerPlayerId", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class ClubShowcaseSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublicClubPage_ShouldRenderMembersOnlyBlock()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
|
||||
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
|
||||
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
|
||||
Assert.Contains("members-only-section", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicClubPage_ShouldRenderApplyAndLoginCtas()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
|
||||
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
|
||||
Assert.Contains("applicationMessage", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicClubPage_ShouldHideMembersOnlyBlockForAnonymous()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
||||
|
||||
// Anonymous users must not see the members-only block content
|
||||
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
|
||||
// Login CTA appears when viewerPlayerId is null
|
||||
Assert.Contains("viewerPlayerId is null", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicLayout_ShouldExposeClubsLink()
|
||||
{
|
||||
var layout = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/PublicLayout.razor");
|
||||
|
||||
Assert.Contains("href=\"/showcase\"", layout, StringComparison.Ordinal);
|
||||
Assert.Contains("Клубы", layout, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NavMenu_ShouldExposeMyClubsLink()
|
||||
{
|
||||
var menu = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/NavMenu.razor");
|
||||
|
||||
Assert.Contains("href=\"profile/memberships\"", menu, StringComparison.Ordinal);
|
||||
Assert.Contains("Мои клубы", menu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupDetails_ShouldExposeApplicationsLink()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
|
||||
Assert.Contains("/applications", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Заявки участников", page, StringComparison.Ordinal);
|
||||
Assert.Contains("pendingApplicationsCount", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupDetails_ShouldUsePublicationModeSelectorNotBooleanToggle()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||
|
||||
Assert.DoesNotContain("SetSessionPublic(session.Id, !session.IsPublic)", page, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SetBatchPublic(batch, !batch.AllSessionsPublic)", page, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicationMode", page, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicationMode", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditSession_ShouldExposePublicationModeSelector()
|
||||
{
|
||||
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/EditSession.razor");
|
||||
|
||||
Assert.Contains("PublicationMode", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Режим публикации", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Catalog", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ClubOnly", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Both", page, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(
|
||||
() => _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<InvalidOperationException>(
|
||||
() => _storage.SaveAsync(stream, "image/png"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_ShouldRejectUnknownContentType()
|
||||
{
|
||||
await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _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<InvalidOperationException>(
|
||||
() => _storage.DeleteIfExistsAsync("../escape.png"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using GmRelay.Web.Services.Portfolio;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioContractsTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioCard>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioGame>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioMaster>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties()
|
||||
{
|
||||
AssertNoForbiddenPropertyNames<PublicPortfolioReview>();
|
||||
}
|
||||
|
||||
[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<T>()
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<PortfolioMigrationPostgresFixture>
|
||||
{
|
||||
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<MigratedPortfolioDatabase> 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<string> 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<NpgsqlConnection> OpenConnectionAsync()
|
||||
{
|
||||
var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
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");
|
||||
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);
|
||||
Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE portfolio_game_reviews", 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 (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("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 (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);
|
||||
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 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("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);
|
||||
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.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);
|
||||
}
|
||||
|
||||
[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 string NormalizeSql(string sql)
|
||||
{
|
||||
return string.Join(' ', sql.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace("( ", "(", StringComparison.Ordinal)
|
||||
.Replace(" )", ")", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[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<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
|
||||
{
|
||||
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
"var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres) .WithHttpEndpoint(port: 8081, targetPort: 8081, name: \"health\", isProxied: false) .WithHttpHealthCheck(\"/health\", endpointName: \"health\");",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_DiscordBot>(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"builder.AddProject<Projects.GmRelay_Web>(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
|
||||
appHost,
|
||||
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);
|
||||
|
||||
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 string NormalizeSource(string source)
|
||||
{
|
||||
return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PortfolioSessionDeletionSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SharedDeleteSessionHandler_ShouldLockSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs"));
|
||||
|
||||
const string sessionLock =
|
||||
"FROM sessions s WHERE s.id = @SessionId FOR UPDATE OF s";
|
||||
const string 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),
|
||||
"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),
|
||||
"Linked public portfolio cards must be unpublished before deleting the session.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscordDeleteSessionHandler_ShouldLockGuildSessionBeforeUnpublishingLinkedPortfolioCardAndDeletingSession()
|
||||
{
|
||||
var source = NormalizeSql(await ReadRepositoryFileAsync(
|
||||
"src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs"));
|
||||
|
||||
const string sessionLock =
|
||||
"SELECT s.id FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND g.platform = 'Discord' AND g.external_group_id = @GuildId FOR UPDATE OF s";
|
||||
const string 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),
|
||||
"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),
|
||||
"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<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void NormalizeReviewBody_ShouldRejectBlankText(string? body)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => PortfolioValidation.NormalizeFormat(format));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,20 @@ public sealed class PublicClubPagesTests
|
||||
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV030_ShouldAddClubMembershipsAndPublicationMode()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
|
||||
|
||||
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("status", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("role", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("publication_mode", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
|
||||
{
|
||||
@@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests
|
||||
|
||||
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal);
|
||||
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.publication_mode IN ('Catalog', 'Both')", service, StringComparison.Ordinal);
|
||||
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
|
||||
}
|
||||
@@ -55,8 +69,8 @@ public sealed class PublicClubPagesTests
|
||||
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
||||
|
||||
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
|
||||
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
|
||||
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace GmRelay.Bot.Tests.Web;
|
||||
|
||||
public sealed class PublicationModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublicationMode_ShouldHaveFourValues()
|
||||
{
|
||||
var values = Enum.GetValues<GmRelay.Shared.Domain.PublicationMode>();
|
||||
Assert.Equal(4, values.Length);
|
||||
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.None, values);
|
||||
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Catalog, values);
|
||||
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.ClubOnly, values);
|
||||
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Both, values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV030_ShouldAddClubMembershipsTable()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
|
||||
|
||||
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("status", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("role", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("Pending", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("Active", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("Rejected", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("Left", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("Member", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV030_ShouldReplaceIsPublicWithPublicationModeEnum()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
|
||||
|
||||
Assert.Contains("ADD COLUMN publication_mode", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ck_sessions_publication_mode", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("'None', 'Catalog', 'ClubOnly', 'Both'", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("UPDATE sessions SET publication_mode = 'None' WHERE is_public = false", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV030_ShouldRecreatePartialIndexUsingPublicationMode()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
|
||||
|
||||
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("publication_mode IN ('Catalog', 'Both')", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV030_ShouldAddPortfolioPublicationModeColumn()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
|
||||
|
||||
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_portfolio_games_showcase", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> 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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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, )"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user